From 70bd109d097933cb3d5c26806f7456d87c47986b Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Wed, 7 Jan 2026 14:51:53 +0100 Subject: [PATCH 1/9] Make some properties public on SessionDefaults to allow external extensions of stored properties. --- .../AppEnvironment/SessionDefaults.swift | 63 +++++++++++-------- 1 file changed, 37 insertions(+), 26 deletions(-) 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] } From 37a43e7e74765cdfa8298e7ce6d0d0ac562a4a7d Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 13 Jan 2026 10:45:55 +0100 Subject: [PATCH 2/9] Add skeleton architecture. --- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../Assets.xcassets/Secrets/Contents.json | 6 +- .../LearnerDashboardAssembly.swift | 4 +- .../Model/LearnerDashboardInteractor.swift | 66 ++++++++ .../View/LearnerDashboardScreen.swift | 14 +- .../ViewModel/LearnerDashboardViewModel.swift | 18 +++ .../Common/Model/LearnerWidgetViewModel.swift | 30 ++++ .../Model/SessionDefaults+WidgetConfig.swift | 40 +++++ .../Widgets/Common/Model/WidgetConfig.swift | 38 +++++ .../Widgets/Common/View/DashboardCard.swift | 35 +++++ .../Common/View/DashboardWidgetLayout.swift | 146 ++++++++++++++++++ .../View/FullWidthWidgetView.swift | 35 +++++ .../ViewModel/FullWidthWidgetViewModel.swift} | 26 ++-- .../LearnerDashboardWidgetAssembly.swift | 59 +++++++ .../Widgets/Widget1/View/Widget1View.swift | 35 +++++ .../Widget1/ViewModel/Widget1ViewModel.swift | 37 +++++ .../Widgets/Widget2/View/Widget2View.swift | 35 +++++ .../Widget2/ViewModel/Widget2ViewModel.swift | 37 +++++ .../Widgets/Widget3/View/Widget3View.swift | 35 +++++ .../Widget3/ViewModel/Widget3ViewModel.swift | 37 +++++ .../Widgets/WidgetIdentifier.swift | 24 +++ Student/Student/Localizable.xcstrings | 24 +++ .../LearnerDashboardInteractorLiveTests.swift | 8 +- .../LearnerDashboardInteractorMock.swift | 5 + 24 files changed, 771 insertions(+), 25 deletions(-) rename Student/Student/LearnerDashboard/{ => Container}/LearnerDashboardAssembly.swift (90%) create mode 100644 Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift rename Student/Student/LearnerDashboard/{ => Container}/View/LearnerDashboardScreen.swift (89%) rename Student/Student/LearnerDashboard/{ => Container}/ViewModel/LearnerDashboardViewModel.swift (78%) create mode 100644 Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift create mode 100644 Student/Student/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfig.swift create mode 100644 Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift create mode 100644 Student/Student/LearnerDashboard/Widgets/Common/View/DashboardCard.swift create mode 100644 Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift create mode 100644 Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift rename Student/Student/LearnerDashboard/{Model/LearnerDashboardInteractor.swift => Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift} (61%) create mode 100644 Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift create mode 100644 Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift create mode 100644 Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift create mode 100644 Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift create mode 100644 Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift create mode 100644 Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift create mode 100644 Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift create mode 100644 Student/Student/LearnerDashboard/Widgets/WidgetIdentifier.swift diff --git a/Canvas.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Canvas.xcworkspace/xcshareddata/swiftpm/Package.resolved index cca55aa1de..28db436e18 100644 --- a/Canvas.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Canvas.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "565a75ce871663053842784aec02a5a61572a36b076109d773d5591c5545b626", + "originHash" : "70dba1bc2853feefc549daad4db2bd02f660c72814964a842e1d0e6ed8db4977", "pins" : [ { "identity" : "abseil-cpp-binary", 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..8b8b59be0e --- /dev/null +++ b/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift @@ -0,0 +1,66 @@ +// +// 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> + func refresh(ignoreCache: Bool) -> AnyPublisher +} + +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() + } + + func refresh(ignoreCache: Bool) -> AnyPublisher { + Just(()) + .delay(for: .seconds(2), scheduler: DispatchQueue.global(qos: .userInitiated)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } +} diff --git a/Student/Student/LearnerDashboard/View/LearnerDashboardScreen.swift b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift similarity index 89% rename from Student/Student/LearnerDashboard/View/LearnerDashboardScreen.swift rename to Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift index 01af95357f..b8e34935c1 100644 --- a/Student/Student/LearnerDashboard/View/LearnerDashboardScreen.swift +++ b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift @@ -57,7 +57,13 @@ struct LearnerDashboardScreen: View { @ViewBuilder private var content: some View { - SwiftUI.EmptyView() + ScrollView { + DashboardWidgetLayout( + fullWidthWidgets: viewModel.fullWidthWidgets, + gridWidgets: viewModel.gridWidgets + ) + .padding(.vertical, 8) + } } } @@ -65,11 +71,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 78% rename from Student/Student/LearnerDashboard/ViewModel/LearnerDashboardViewModel.swift rename to Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift index 3ccbc089dd..e6b1a4f21e 100644 --- a/Student/Student/LearnerDashboard/ViewModel/LearnerDashboardViewModel.swift +++ b/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift @@ -25,6 +25,8 @@ 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, @@ -48,9 +50,25 @@ final class LearnerDashboardViewModel { ) { self.interactor = interactor self.mainScheduler = mainScheduler + + loadWidgets() refresh(ignoreCache: false) } + 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 + } + } + .store(in: &subscriptions) + } + func refresh(ignoreCache: Bool, completion: (() -> Void)? = nil) { interactor.refresh(ignoreCache: ignoreCache) .receive(on: mainScheduler) 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..bdd295e4d9 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.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 SwiftUI + +protocol LearnerWidgetViewModel: AnyObject, Identifiable where ID == WidgetIdentifier { + associatedtype ViewType: View + + var config: WidgetConfig { get } + var id: WidgetIdentifier { get } + var isFullWidth: Bool { get } + var isEditable: Bool { get } + + func makeView() -> ViewType +} 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..b901bf4033 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.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 . +// + +struct WidgetConfig: Codable, Comparable, Identifiable { + let id: WidgetIdentifier + var order: Int + var isVisible: Bool + 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/View/DashboardCard.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardCard.swift new file mode 100644 index 0000000000..fdbb2499f6 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardCard.swift @@ -0,0 +1,35 @@ +// +// 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 + +struct DashboardCard: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .padding(16) + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2) + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift new file mode 100644 index 0000000000..747be9c8f7 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift @@ -0,0 +1,146 @@ +// +// 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 + +struct DashboardWidgetLayout: View { + let fullWidthWidgets: [any LearnerWidgetViewModel] + let gridWidgets: [any LearnerWidgetViewModel] + + var body: some View { + GeometryReader { geometry in + let columnCount = columns(for: geometry.size.width) + + VStack(spacing: 0) { + fullWidthSection() + gridSection(columnCount: columnCount) + } + .frame(width: geometry.size.width, alignment: .top) + .animation(.easeInOut(duration: 0.3), value: columnCount) + } + } + + @ViewBuilder + private func fullWidthSection() -> some View { + ForEach(fullWidthWidgets, id: \.id) { viewModel in + LearnerDashboardWidgetAssembly.makeView(for: viewModel) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } + + @ViewBuilder + private func gridSection(columnCount: Int) -> some View { + if !gridWidgets.isEmpty { + HStack(alignment: .top, spacing: 16) { + ForEach(0.. some View { + LazyVStack(spacing: 16) { + ForEach(Array(gridWidgets.enumerated()), id: \.offset) { index, viewModel in + if index % columnCount == columnIndex { + LearnerDashboardWidgetAssembly.makeView(for: viewModel) + } + } + } + .frame(maxWidth: .infinity) + } + + private func columns(for width: CGFloat) -> Int { + switch width { + case ..<600: 1 + case 600..<840: 2 + default: 3 + } + } +} + +#if DEBUG + +#Preview("Mixed: Full Width + Grid") { + ScrollView { + DashboardWidgetLayout( + fullWidthWidgets: [ + LearnerDashboardWidgetAssembly.makeWidgetViewModel( + config: WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil) + ) + ], + gridWidgets: [ + LearnerDashboardWidgetAssembly.makeWidgetViewModel( + config: WidgetConfig(id: .widget1, order: 1, isVisible: true, settings: nil) + ), + LearnerDashboardWidgetAssembly.makeWidgetViewModel( + config: WidgetConfig(id: .widget2, order: 2, isVisible: true, settings: nil) + ), + LearnerDashboardWidgetAssembly.makeWidgetViewModel( + config: WidgetConfig(id: .widget3, order: 3, isVisible: true, settings: nil) + ) + ] + ) + } +} + +#Preview("Only Grid Widgets") { + ScrollView { + DashboardWidgetLayout( + fullWidthWidgets: [], + gridWidgets: [ + LearnerDashboardWidgetAssembly.makeWidgetViewModel( + config: WidgetConfig(id: .widget1, order: 0, isVisible: true, settings: nil) + ), + LearnerDashboardWidgetAssembly.makeWidgetViewModel( + config: WidgetConfig(id: .widget2, order: 1, isVisible: true, settings: nil) + ), + LearnerDashboardWidgetAssembly.makeWidgetViewModel( + config: WidgetConfig(id: .widget3, order: 2, isVisible: true, settings: nil) + ) + ] + ) + } +} + +#Preview("Only Full Width Widgets") { + ScrollView { + DashboardWidgetLayout( + fullWidthWidgets: [ + LearnerDashboardWidgetAssembly.makeWidgetViewModel( + config: WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil) + ) + ], + gridWidgets: [] + ) + } +} + +#Preview("Empty State") { + ScrollView { + DashboardWidgetLayout( + fullWidthWidgets: [], + gridWidgets: [] + ) + } +} + +#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..82a49f1fc7 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift @@ -0,0 +1,35 @@ +// +// 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 + +struct FullWidthWidgetView: View { + @State var viewModel: FullWidthWidgetViewModel + + var body: some View { + DashboardCard { + VStack(alignment: .leading) { + Text("Full Width Widget", bundle: .student) + .font(.headline) + Text("This is a full width widget", bundle: .student) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/Student/Student/LearnerDashboard/Model/LearnerDashboardInteractor.swift b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift similarity index 61% rename from Student/Student/LearnerDashboard/Model/LearnerDashboardInteractor.swift rename to Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift index 917cd84524..152144eb77 100644 --- a/Student/Student/LearnerDashboard/Model/LearnerDashboardInteractor.swift +++ b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift @@ -16,20 +16,22 @@ // along with this program. If not, see . // -import Combine -import Core -import Foundation +import SwiftUI -protocol LearnerDashboardInteractor { - func refresh(ignoreCache: Bool) -> AnyPublisher -} +@Observable +final class FullWidthWidgetViewModel: LearnerWidgetViewModel { + typealias ViewType = FullWidthWidgetView + + let config: WidgetConfig + var id: WidgetIdentifier { config.id } + let isFullWidth = true + let isEditable = false -final class LearnerDashboardInteractorLive: LearnerDashboardInteractor { + init(config: WidgetConfig) { + self.config = config + } - func refresh(ignoreCache: Bool) -> AnyPublisher { - Just(()) - .delay(for: .seconds(2), scheduler: DispatchQueue.global(qos: .userInitiated)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + func makeView() -> FullWidthWidgetView { + FullWidthWidgetView(viewModel: self) } } diff --git a/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift new file mode 100644 index 0000000000..317e39dc8e --- /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: .widget2, order: 2, isVisible: true, settings: nil), + WidgetConfig(id: .widget3, order: 3, 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/Widgets/Widget1/View/Widget1View.swift b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift new file mode 100644 index 0000000000..1a3fdc557e --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift @@ -0,0 +1,35 @@ +// +// 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 + +struct Widget1View: View { + @State var viewModel: Widget1ViewModel + + var body: some View { + DashboardCard { + VStack(alignment: .leading) { + Text("Widget 1", bundle: .student) + .font(.headline) + Text("Content for widget 1", bundle: .student) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} 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..80631c121e --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift @@ -0,0 +1,37 @@ +// +// 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 + +@Observable +final class Widget1ViewModel: LearnerWidgetViewModel { + typealias ViewType = Widget1View + + let config: WidgetConfig + var id: WidgetIdentifier { config.id } + let isFullWidth = false + let isEditable = false + + init(config: WidgetConfig) { + self.config = config + } + + func makeView() -> Widget1View { + Widget1View(viewModel: self) + } +} 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..2bb8cbeaae --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift @@ -0,0 +1,35 @@ +// +// 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 + +struct Widget2View: View { + @State var viewModel: Widget2ViewModel + + var body: some View { + DashboardCard { + VStack(alignment: .leading) { + Text("Widget 2", bundle: .student) + .font(.headline) + Text("Content for widget 2", bundle: .student) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} 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..013d55182c --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift @@ -0,0 +1,37 @@ +// +// 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 + +@Observable +final class Widget2ViewModel: LearnerWidgetViewModel { + typealias ViewType = Widget2View + + let config: WidgetConfig + var id: WidgetIdentifier { config.id } + let isFullWidth = false + let isEditable = false + + init(config: WidgetConfig) { + self.config = config + } + + func makeView() -> Widget2View { + Widget2View(viewModel: self) + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift b/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift new file mode 100644 index 0000000000..7a96003184 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift @@ -0,0 +1,35 @@ +// +// 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 + +struct Widget3View: View { + @State var viewModel: Widget3ViewModel + + var body: some View { + DashboardCard { + VStack(alignment: .leading) { + Text("Widget 3", bundle: .student) + .font(.headline) + Text("Content for widget 3", bundle: .student) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} 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..1b5cc254b6 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift @@ -0,0 +1,37 @@ +// +// 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 + +@Observable +final class Widget3ViewModel: LearnerWidgetViewModel { + typealias ViewType = Widget3View + + let config: WidgetConfig + var id: WidgetIdentifier { config.id } + let isFullWidth = false + let isEditable = false + + init(config: WidgetConfig) { + self.config = config + } + + func makeView() -> Widget3View { + Widget3View(viewModel: self) + } +} diff --git a/Student/Student/LearnerDashboard/Widgets/WidgetIdentifier.swift b/Student/Student/LearnerDashboard/Widgets/WidgetIdentifier.swift new file mode 100644 index 0000000000..db99bf062a --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/WidgetIdentifier.swift @@ -0,0 +1,24 @@ +// +// 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 . +// + +enum WidgetIdentifier: String, Codable, CaseIterable { + case fullWidthWidget + case widget1 + case widget2 + case widget3 +} diff --git a/Student/Student/Localizable.xcstrings b/Student/Student/Localizable.xcstrings index 3bf171dcf8..d419ba27f4 100644 --- a/Student/Student/Localizable.xcstrings +++ b/Student/Student/Localizable.xcstrings @@ -36459,6 +36459,15 @@ } } } + }, + "Content for widget 1" : { + + }, + "Content for widget 2" : { + + }, + "Content for widget 3" : { + }, "Continue" : { "comment" : "Button title for selecting to continue on to quiz. The will appear in the alert view notfifying the user of mobile quiz limitations.", @@ -57680,6 +57689,9 @@ } } } + }, + "Full Width Widget" : { + }, "Future" : { "extractionState" : "manual", @@ -130173,6 +130185,9 @@ } } } + }, + "This is a full width widget" : { + }, "This is a media message." : { "extractionState" : "manual", @@ -143528,6 +143543,15 @@ } } } + }, + "Widget 1" : { + + }, + "Widget 2" : { + + }, + "Widget 3" : { + }, "Will unlock %@." : { "comment" : "unlock date for discussion/announcement", diff --git a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift b/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift index 4058e689a2..75b5878d0c 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift @@ -22,13 +22,17 @@ import XCTest final class LearnerDashboardInteractorLiveTests: StudentTestCase { func test_refresh_whenIgnoreCacheIsTrue_shouldFinish() { - let testee = LearnerDashboardInteractorLive() + let testee = LearnerDashboardInteractorLive( + widgetViewModelFactory: LearnerDashboardWidgetAssembly.makeWidgetViewModel + ) XCTAssertFinish(testee.refresh(ignoreCache: true), timeout: 3) } func test_refresh_whenIgnoreCacheIsFalse_shouldFinish() { - let testee = LearnerDashboardInteractorLive() + let testee = LearnerDashboardInteractorLive( + widgetViewModelFactory: LearnerDashboardWidgetAssembly.makeWidgetViewModel + ) XCTAssertFinish(testee.refresh(ignoreCache: false), timeout: 3) } diff --git a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift b/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift index 11e2c1f0f2..8d7a2630d1 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift @@ -20,9 +20,14 @@ import Combine @testable import Student final class LearnerDashboardInteractorMock: LearnerDashboardInteractor { + var loadWidgetsPublisher = PassthroughSubject<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never>() var refreshIgnoreCacheValue: Bool? var refreshPublisher = PassthroughSubject() + func loadWidgets() -> AnyPublisher<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never> { + loadWidgetsPublisher.eraseToAnyPublisher() + } + func refresh(ignoreCache: Bool) -> AnyPublisher { refreshIgnoreCacheValue = ignoreCache return refreshPublisher.eraseToAnyPublisher() From bce4cae9d9392faa4522f6871ef89ca565125188 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 13 Jan 2026 12:17:20 +0100 Subject: [PATCH 3/9] Style updates. --- .../View/LearnerDashboardScreen.swift | 1 - ...ardCard.swift => DashboardCardStyle.swift} | 20 +++-- .../Common/View/DashboardWidgetLayout.swift | 74 ++++--------------- .../Widgets/Common/View/TitledWidget.swift | 54 ++++++++++++++ .../View/FullWidthWidgetView.swift | 17 +++-- .../Widgets/Widget1/View/Widget1View.swift | 9 ++- .../Widgets/Widget2/View/Widget2View.swift | 9 ++- .../Widgets/Widget3/View/Widget3View.swift | 9 ++- 8 files changed, 103 insertions(+), 90 deletions(-) rename Student/Student/LearnerDashboard/Widgets/Common/View/{DashboardCard.swift => DashboardCardStyle.swift} (69%) create mode 100644 Student/Student/LearnerDashboard/Widgets/Common/View/TitledWidget.swift diff --git a/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift index b8e34935c1..b4d9e02ba5 100644 --- a/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift +++ b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift @@ -62,7 +62,6 @@ struct LearnerDashboardScreen: View { fullWidthWidgets: viewModel.fullWidthWidgets, gridWidgets: viewModel.gridWidgets ) - .padding(.vertical, 8) } } } diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardCard.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardCardStyle.swift similarity index 69% rename from Student/Student/LearnerDashboard/Widgets/Common/View/DashboardCard.swift rename to Student/Student/LearnerDashboard/Widgets/Common/View/DashboardCardStyle.swift index fdbb2499f6..ef5f07bb13 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardCard.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardCardStyle.swift @@ -17,19 +17,17 @@ // import SwiftUI +import Core -struct DashboardCard: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() +struct DashboardCardStyle: ViewModifier { + func body(content: Content) -> some View { + content + .elevation(.cardLarge, background: .backgroundLightest) } +} - var body: some View { - content - .padding(16) - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2) +extension View { + func dashboardCardStyle() -> some View { + modifier(DashboardCardStyle()) } } diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift index 747be9c8f7..459a519e32 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift @@ -16,56 +16,54 @@ // along with this program. If not, see . // +import Core import SwiftUI struct DashboardWidgetLayout: View { let fullWidthWidgets: [any LearnerWidgetViewModel] let gridWidgets: [any LearnerWidgetViewModel] - var body: some View { - GeometryReader { geometry in - let columnCount = columns(for: geometry.size.width) + @State private var containerWidth: CGFloat = 0 - VStack(spacing: 0) { - fullWidthSection() - gridSection(columnCount: columnCount) - } - .frame(width: geometry.size.width, alignment: .top) - .animation(.easeInOut(duration: 0.3), value: columnCount) + var body: some View { + VStack(spacing: 0) { + fullWidthSection() + gridSection(columnCount: columns(for: containerWidth)) } + .onWidthChange { width in + containerWidth = width + } + .animation(.easeInOut(duration: 0.3), value: columns(for: containerWidth)) } @ViewBuilder private func fullWidthSection() -> some View { ForEach(fullWidthWidgets, id: \.id) { viewModel in LearnerDashboardWidgetAssembly.makeView(for: viewModel) - .padding(.horizontal, 16) - .padding(.vertical, 8) + .paddingStyle(.top, .standard) } } @ViewBuilder private func gridSection(columnCount: Int) -> some View { if !gridWidgets.isEmpty { - HStack(alignment: .top, spacing: 16) { + HStack(alignment: .top, spacing: InstUI.Styles.Padding.standard.rawValue) { ForEach(0.. some View { - LazyVStack(spacing: 16) { + LazyVStack(spacing: InstUI.Styles.Padding.standard.rawValue) { ForEach(Array(gridWidgets.enumerated()), id: \.offset) { index, viewModel in if index % columnCount == columnIndex { LearnerDashboardWidgetAssembly.makeView(for: viewModel) } } } - .frame(maxWidth: .infinity) } private func columns(for width: CGFloat) -> Int { @@ -79,7 +77,7 @@ struct DashboardWidgetLayout: View { #if DEBUG -#Preview("Mixed: Full Width + Grid") { +#Preview { ScrollView { DashboardWidgetLayout( fullWidthWidgets: [ @@ -99,47 +97,7 @@ struct DashboardWidgetLayout: View { ) ] ) - } -} - -#Preview("Only Grid Widgets") { - ScrollView { - DashboardWidgetLayout( - fullWidthWidgets: [], - gridWidgets: [ - LearnerDashboardWidgetAssembly.makeWidgetViewModel( - config: WidgetConfig(id: .widget1, order: 0, isVisible: true, settings: nil) - ), - LearnerDashboardWidgetAssembly.makeWidgetViewModel( - config: WidgetConfig(id: .widget2, order: 1, isVisible: true, settings: nil) - ), - LearnerDashboardWidgetAssembly.makeWidgetViewModel( - config: WidgetConfig(id: .widget3, order: 2, isVisible: true, settings: nil) - ) - ] - ) - } -} - -#Preview("Only Full Width Widgets") { - ScrollView { - DashboardWidgetLayout( - fullWidthWidgets: [ - LearnerDashboardWidgetAssembly.makeWidgetViewModel( - config: WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil) - ) - ], - gridWidgets: [] - ) - } -} - -#Preview("Empty State") { - ScrollView { - DashboardWidgetLayout( - fullWidthWidgets: [], - gridWidgets: [] - ) + .paddingStyle(.horizontal, .standard) } } diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/TitledWidget.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/TitledWidget.swift new file mode 100644 index 0000000000..c6054ebdb5 --- /dev/null +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/TitledWidget.swift @@ -0,0 +1,54 @@ +// +// 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 TitledWidget: 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 { + TitledWidget("Weekly Summary") { + Text(verbatim: InstUI.PreviewData.loremIpsumLong(1)) + .paddingStyle(.standard) + .dashboardCardStyle() + } +} + +#endif diff --git a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift index 82a49f1fc7..a895443d5a 100644 --- a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift +++ b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift @@ -16,20 +16,21 @@ // along with this program. If not, see . // +import Core import SwiftUI struct FullWidthWidgetView: View { @State var viewModel: FullWidthWidgetViewModel var body: some View { - DashboardCard { - VStack(alignment: .leading) { - Text("Full Width Widget", bundle: .student) - .font(.headline) - Text("This is a full width widget", bundle: .student) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading) { + Text(verbatim: "Full Width Widget") + .font(.headline) + Text(verbatim: InstUI.PreviewData.loremIpsumLong(2)) + .foregroundColor(.secondary) } + .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) + .dashboardCardStyle() } } diff --git a/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift index 1a3fdc557e..5aa4f376e4 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift @@ -16,20 +16,21 @@ // along with this program. If not, see . // +import Core import SwiftUI struct Widget1View: View { @State var viewModel: Widget1ViewModel var body: some View { - DashboardCard { + TitledWidget("Widget 1") { VStack(alignment: .leading) { - Text("Widget 1", bundle: .student) - .font(.headline) - Text("Content for widget 1", bundle: .student) + Text(verbatim: InstUI.PreviewData.loremIpsumLong(1)) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) + .dashboardCardStyle() } } } diff --git a/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift b/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift index 2bb8cbeaae..15615cc0e9 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift @@ -16,20 +16,21 @@ // along with this program. If not, see . // +import Core import SwiftUI struct Widget2View: View { @State var viewModel: Widget2ViewModel var body: some View { - DashboardCard { + TitledWidget("Widget 2") { VStack(alignment: .leading) { - Text("Widget 2", bundle: .student) - .font(.headline) - Text("Content for widget 2", bundle: .student) + Text(verbatim: InstUI.PreviewData.loremIpsumLong(2)) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) + .dashboardCardStyle() } } } diff --git a/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift b/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift index 7a96003184..366484df66 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift @@ -16,20 +16,21 @@ // along with this program. If not, see . // +import Core import SwiftUI struct Widget3View: View { @State var viewModel: Widget3ViewModel var body: some View { - DashboardCard { + TitledWidget("Widget 3") { VStack(alignment: .leading) { - Text("Widget 3", bundle: .student) - .font(.headline) - Text("Content for widget 3", bundle: .student) + Text(verbatim: InstUI.PreviewData.loremIpsumLong(3)) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) + .dashboardCardStyle() } } } From 901b44e60043d330dd0e06119fb0ea0bcc1f36ec Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 13 Jan 2026 17:09:38 +0100 Subject: [PATCH 4/9] Add state to widgets. Add example transitions. Fix animations and paddings. --- .../Model/LearnerDashboardInteractor.swift | 8 --- .../View/LearnerDashboardScreen.swift | 1 + .../ViewModel/LearnerDashboardViewModel.swift | 13 +++-- .../Common/Model/LearnerWidgetViewModel.swift | 4 ++ .../Common/View/DashboardWidgetLayout.swift | 49 ++++++++++--------- .../View/FullWidthWidgetView.swift | 17 ++++--- .../ViewModel/FullWidthWidgetViewModel.swift | 22 +++++++++ .../Widgets/Widget1/View/Widget1View.swift | 2 + .../Widget1/ViewModel/Widget1ViewModel.swift | 13 +++++ .../Widget2/ViewModel/Widget2ViewModel.swift | 7 +++ .../Widget3/ViewModel/Widget3ViewModel.swift | 7 +++ Student/Student/Localizable.xcstrings | 24 --------- .../LearnerDashboardInteractorLiveTests.swift | 12 +---- .../LearnerDashboardInteractorMock.swift | 7 --- .../LearnerDashboardViewModelTests.swift | 46 +++++++---------- 15 files changed, 119 insertions(+), 113 deletions(-) diff --git a/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift b/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift index 8b8b59be0e..b6ebd17135 100644 --- a/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift +++ b/Student/Student/LearnerDashboard/Container/Model/LearnerDashboardInteractor.swift @@ -22,7 +22,6 @@ import Foundation protocol LearnerDashboardInteractor { func loadWidgets() -> AnyPublisher<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never> - func refresh(ignoreCache: Bool) -> AnyPublisher } final class LearnerDashboardInteractorLive: LearnerDashboardInteractor { @@ -56,11 +55,4 @@ final class LearnerDashboardInteractorLive: LearnerDashboardInteractor { } .eraseToAnyPublisher() } - - func refresh(ignoreCache: Bool) -> AnyPublisher { - Just(()) - .delay(for: .seconds(2), scheduler: DispatchQueue.global(qos: .userInitiated)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } } diff --git a/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift index b4d9e02ba5..5486ae4d13 100644 --- a/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift +++ b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift @@ -62,6 +62,7 @@ struct LearnerDashboardScreen: View { fullWidthWidgets: viewModel.fullWidthWidgets, gridWidgets: viewModel.gridWidgets ) + .paddingStyle(.all, .standard) } } } diff --git a/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift b/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift index e6b1a4f21e..bd3b4e2a4a 100644 --- a/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift +++ b/Student/Student/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModel.swift @@ -30,6 +30,7 @@ final class LearnerDashboardViewModel { let screenConfig = InstUI.BaseScreenConfig( refreshable: true, + showsScrollIndicators: false, emptyPandaConfig: .init( scene: SpacePanda(), title: String(localized: "Welcome to Canvas!", bundle: .student), @@ -52,7 +53,6 @@ final class LearnerDashboardViewModel { self.mainScheduler = mainScheduler loadWidgets() - refresh(ignoreCache: false) } private func loadWidgets() { @@ -65,18 +65,21 @@ final class LearnerDashboardViewModel { 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 index bdd295e4d9..26f09fe893 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift @@ -16,6 +16,8 @@ // along with this program. If not, see . // +import Combine +import Core import SwiftUI protocol LearnerWidgetViewModel: AnyObject, Identifiable where ID == WidgetIdentifier { @@ -25,6 +27,8 @@ protocol LearnerWidgetViewModel: AnyObject, Identifiable where ID == WidgetIdent var id: WidgetIdentifier { get } var isFullWidth: Bool { get } var isEditable: Bool { get } + var state: InstUI.ScreenState { get } func makeView() -> ViewType + func refresh(ignoreCache: Bool) -> AnyPublisher } diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift index 459a519e32..7c45b70bcc 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift @@ -22,25 +22,25 @@ import SwiftUI struct DashboardWidgetLayout: View { let fullWidthWidgets: [any LearnerWidgetViewModel] let gridWidgets: [any LearnerWidgetViewModel] - @State private var containerWidth: CGFloat = 0 var body: some View { - VStack(spacing: 0) { + VStack(spacing: InstUI.Styles.Padding.standard.rawValue) { fullWidthSection() gridSection(columnCount: columns(for: containerWidth)) } .onWidthChange { width in - containerWidth = width + // Don't animate the first appearance + withAnimation(containerWidth == 0 ? .none : .smooth) { + containerWidth = width + } } - .animation(.easeInOut(duration: 0.3), value: columns(for: containerWidth)) } @ViewBuilder private func fullWidthSection() -> some View { ForEach(fullWidthWidgets, id: \.id) { viewModel in LearnerDashboardWidgetAssembly.makeView(for: viewModel) - .paddingStyle(.top, .standard) } } @@ -52,7 +52,6 @@ struct DashboardWidgetLayout: View { columnView(columnIndex: columnIndex, columnCount: columnCount) } } - .paddingStyle(.top, .standard) } } @@ -78,24 +77,28 @@ struct DashboardWidgetLayout: View { #if DEBUG #Preview { - ScrollView { + 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 { DashboardWidgetLayout( - fullWidthWidgets: [ - LearnerDashboardWidgetAssembly.makeWidgetViewModel( - config: WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil) - ) - ], - gridWidgets: [ - LearnerDashboardWidgetAssembly.makeWidgetViewModel( - config: WidgetConfig(id: .widget1, order: 1, isVisible: true, settings: nil) - ), - LearnerDashboardWidgetAssembly.makeWidgetViewModel( - config: WidgetConfig(id: .widget2, order: 2, isVisible: true, settings: nil) - ), - LearnerDashboardWidgetAssembly.makeWidgetViewModel( - config: WidgetConfig(id: .widget3, order: 3, isVisible: true, settings: nil) - ) - ] + fullWidthWidgets: [fullWidthWidget], + gridWidgets: [widget1, widget2, widget3] ) .paddingStyle(.horizontal, .standard) } diff --git a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift index a895443d5a..f717902004 100644 --- a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift +++ b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift @@ -23,14 +23,15 @@ struct FullWidthWidgetView: View { @State var viewModel: FullWidthWidgetViewModel var body: some View { - VStack(alignment: .leading) { - Text(verbatim: "Full Width Widget") - .font(.headline) - Text(verbatim: InstUI.PreviewData.loremIpsumLong(2)) - .foregroundColor(.secondary) + if viewModel.state != .loading { + TitledWidget("Full Width Widget") { + Text(verbatim: InstUI.PreviewData.loremIpsumMedium) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) + .dashboardCardStyle() + } + .transition(.move(edge: .top).combined(with: .opacity)) } - .frame(maxWidth: .infinity, alignment: .leading) - .paddingStyle(.standard) - .dashboardCardStyle() } } diff --git a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift index 152144eb77..58afb80b37 100644 --- a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift @@ -16,6 +16,8 @@ // along with this program. If not, see . // +import Combine +import Core import SwiftUI @Observable @@ -26,6 +28,7 @@ final class FullWidthWidgetViewModel: LearnerWidgetViewModel { var id: WidgetIdentifier { config.id } let isFullWidth = true let isEditable = false + var state: InstUI.ScreenState = .loading init(config: WidgetConfig) { self.config = config @@ -34,4 +37,23 @@ final class FullWidthWidgetViewModel: LearnerWidgetViewModel { 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/Widget1/View/Widget1View.swift b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift index 5aa4f376e4..5e99c28987 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift @@ -30,7 +30,9 @@ struct Widget1View: View { } .frame(maxWidth: .infinity, alignment: .leading) .paddingStyle(.standard) + .redacted(reason: viewModel.state == .data ? [] : .placeholder) .dashboardCardStyle() + .animation(.smooth, value: viewModel.state) } } } diff --git a/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift index 80631c121e..5cb6ef97d0 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift @@ -16,6 +16,8 @@ // along with this program. If not, see . // +import Combine +import Core import SwiftUI @Observable @@ -26,6 +28,7 @@ final class Widget1ViewModel: LearnerWidgetViewModel { var id: WidgetIdentifier { config.id } let isFullWidth = false let isEditable = false + var state: InstUI.ScreenState = .loading init(config: WidgetConfig) { self.config = config @@ -34,4 +37,14 @@ final class Widget1ViewModel: LearnerWidgetViewModel { 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/ViewModel/Widget2ViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift index 013d55182c..4fec3ac484 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift @@ -16,6 +16,8 @@ // along with this program. If not, see . // +import Combine +import Core import SwiftUI @Observable @@ -26,6 +28,7 @@ final class Widget2ViewModel: LearnerWidgetViewModel { var id: WidgetIdentifier { config.id } let isFullWidth = false let isEditable = false + var state: InstUI.ScreenState = .data init(config: WidgetConfig) { self.config = config @@ -34,4 +37,8 @@ final class Widget2ViewModel: LearnerWidgetViewModel { func makeView() -> Widget2View { Widget2View(viewModel: self) } + + func refresh(ignoreCache: Bool) -> AnyPublisher { + Just(()).eraseToAnyPublisher() + } } diff --git a/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift index 1b5cc254b6..6709f00033 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift @@ -16,6 +16,8 @@ // along with this program. If not, see . // +import Combine +import Core import SwiftUI @Observable @@ -26,6 +28,7 @@ final class Widget3ViewModel: LearnerWidgetViewModel { var id: WidgetIdentifier { config.id } let isFullWidth = false let isEditable = false + var state: InstUI.ScreenState = .data init(config: WidgetConfig) { self.config = config @@ -34,4 +37,8 @@ final class Widget3ViewModel: LearnerWidgetViewModel { 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 d419ba27f4..3bf171dcf8 100644 --- a/Student/Student/Localizable.xcstrings +++ b/Student/Student/Localizable.xcstrings @@ -36459,15 +36459,6 @@ } } } - }, - "Content for widget 1" : { - - }, - "Content for widget 2" : { - - }, - "Content for widget 3" : { - }, "Continue" : { "comment" : "Button title for selecting to continue on to quiz. The will appear in the alert view notfifying the user of mobile quiz limitations.", @@ -57689,9 +57680,6 @@ } } } - }, - "Full Width Widget" : { - }, "Future" : { "extractionState" : "manual", @@ -130185,9 +130173,6 @@ } } } - }, - "This is a full width widget" : { - }, "This is a media message." : { "extractionState" : "manual", @@ -143543,15 +143528,6 @@ } } } - }, - "Widget 1" : { - - }, - "Widget 2" : { - - }, - "Widget 3" : { - }, "Will unlock %@." : { "comment" : "unlock date for discussion/announcement", diff --git a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift b/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift index 75b5878d0c..8d4d273f9b 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift @@ -21,19 +21,11 @@ import XCTest final class LearnerDashboardInteractorLiveTests: StudentTestCase { - func test_refresh_whenIgnoreCacheIsTrue_shouldFinish() { + func test_loadWidgets_shouldReturnWidgets() { let testee = LearnerDashboardInteractorLive( widgetViewModelFactory: LearnerDashboardWidgetAssembly.makeWidgetViewModel ) - XCTAssertFinish(testee.refresh(ignoreCache: true), timeout: 3) - } - - func test_refresh_whenIgnoreCacheIsFalse_shouldFinish() { - let testee = LearnerDashboardInteractorLive( - widgetViewModelFactory: LearnerDashboardWidgetAssembly.makeWidgetViewModel - ) - - XCTAssertFinish(testee.refresh(ignoreCache: false), timeout: 3) + XCTAssertFinish(testee.loadWidgets(), timeout: 3) } } diff --git a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift b/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift index 8d7a2630d1..742f6d3151 100644 --- a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift +++ b/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift @@ -21,15 +21,8 @@ import Combine final class LearnerDashboardInteractorMock: LearnerDashboardInteractor { var loadWidgetsPublisher = PassthroughSubject<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never>() - var refreshIgnoreCacheValue: Bool? - var refreshPublisher = PassthroughSubject() func loadWidgets() -> AnyPublisher<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never> { loadWidgetsPublisher.eraseToAnyPublisher() } - - func refresh(ignoreCache: Bool) -> AnyPublisher { - refreshIgnoreCacheValue = ignoreCache - return refreshPublisher.eraseToAnyPublisher() - } } diff --git a/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift index 3f261ea599..c8ec2937a4 100644 --- a/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift +++ b/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift @@ -49,52 +49,42 @@ final class LearnerDashboardViewModelTests: StudentTestCase { XCTAssertEqual(testee.screenConfig.emptyPandaConfig.title, expectedTitle) } - func test_refresh_shouldSetStateToEmpty() { - let expectation = expectation(description: "Refresh should complete") - + func test_loadWidgets_whenNoWidgets_shouldSetStateToEmpty() { // WHEN - testee.refresh(ignoreCache: true) { - expectation.fulfill() - } - - interactor.refreshPublisher.send(()) - interactor.refreshPublisher.send(completion: .finished) + interactor.loadWidgetsPublisher.send((fullWidth: [], grid: [])) scheduler.advance() // THEN - wait(for: [expectation], timeout: 1) - XCTAssertEqual(testee.state, .empty) + XCTAssertEqual(testee.state, .loading) } - func test_refresh_whenIgnoreCacheIsTrue_shouldCallInteractorWithCorrectParameter() { - let expectedIgnoreCache = true + func test_loadWidgets_whenWidgetsExist_shouldSetStateToData() { + let widget = FullWidthWidgetViewModel(config: WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil)) // WHEN - testee.refresh(ignoreCache: expectedIgnoreCache) - - interactor.refreshPublisher.send(()) - interactor.refreshPublisher.send(completion: .finished) + interactor.loadWidgetsPublisher.send((fullWidth: [widget], grid: [])) scheduler.advance() // THEN - XCTAssertEqual(interactor.refreshIgnoreCacheValue, expectedIgnoreCache) + XCTAssertEqual(testee.state, .data) + XCTAssertEqual(testee.fullWidthWidgets.count, 1) + XCTAssertEqual(testee.gridWidgets.count, 0) } - func test_refresh_whenIgnoreCacheIsFalse_shouldCallInteractorWithCorrectParameter() { - let expectedIgnoreCache = false + func test_refresh_shouldComplete() { + let widget = FullWidthWidgetViewModel(config: WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil)) + let expectation = expectation(description: "Refresh should complete") - interactor.refreshPublisher.send(()) - interactor.refreshPublisher.send(completion: .finished) + interactor.loadWidgetsPublisher.send((fullWidth: [widget], grid: [])) scheduler.advance() // WHEN - testee.refresh(ignoreCache: expectedIgnoreCache) - - interactor.refreshPublisher.send(()) - interactor.refreshPublisher.send(completion: .finished) - scheduler.advance() + testee.refresh(ignoreCache: true) { + expectation.fulfill() + } + scheduler.advance(by: 2.1) // THEN - XCTAssertEqual(interactor.refreshIgnoreCacheValue, expectedIgnoreCache) + wait(for: [expectation], timeout: 3) } } From 281a8b70ba1d43795e6213016d29db639c72e41a Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Wed, 14 Jan 2026 14:46:33 +0100 Subject: [PATCH 5/9] Animation fixes. Renaming. --- .../View/LearnerDashboardScreen.swift | 12 ++-- .../Common/Model/LearnerWidgetViewModel.swift | 7 +- .../Widgets/Common/Model/WidgetConfig.swift | 2 +- ...Style.swift => LearnerDashboardCard.swift} | 31 ++++++-- ...ift => LearnerDashboardTitledWidget.swift} | 8 +-- .../LearnerDashboardWidgetErrorView.swift | 70 +++++++++++++++++++ ...ift => LearnerDashboardWidgetLayout.swift} | 8 ++- .../View/FullWidthWidgetView.swift | 13 ++-- .../ViewModel/FullWidthWidgetViewModel.swift | 2 +- .../LearnerDashboardWidgetAssembly.swift | 4 +- ...=> LearnerDashboardWidgetIdentifier.swift} | 2 +- .../Widgets/Widget1/View/Widget1View.swift | 19 ++--- .../Widget1/ViewModel/Widget1ViewModel.swift | 2 +- .../Widgets/Widget2/View/Widget2View.swift | 15 ++-- .../Widget2/ViewModel/Widget2ViewModel.swift | 2 +- .../Widgets/Widget3/View/Widget3View.swift | 25 +++++-- .../Widget3/ViewModel/Widget3ViewModel.swift | 17 ++++- Student/Student/Localizable.xcstrings | 9 +++ 18 files changed, 186 insertions(+), 62 deletions(-) rename Student/Student/LearnerDashboard/Widgets/Common/View/{DashboardCardStyle.swift => LearnerDashboardCard.swift} (54%) rename Student/Student/LearnerDashboard/Widgets/Common/View/{TitledWidget.swift => LearnerDashboardTitledWidget.swift} (86%) create mode 100644 Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetErrorView.swift rename Student/Student/LearnerDashboard/Widgets/Common/View/{DashboardWidgetLayout.swift => LearnerDashboardWidgetLayout.swift} (91%) rename Student/Student/LearnerDashboard/Widgets/{WidgetIdentifier.swift => LearnerDashboardWidgetIdentifier.swift} (92%) diff --git a/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift index 5486ae4d13..a079caa57e 100644 --- a/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift +++ b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift @@ -57,13 +57,11 @@ struct LearnerDashboardScreen: View { @ViewBuilder private var content: some View { - ScrollView { - DashboardWidgetLayout( - fullWidthWidgets: viewModel.fullWidthWidgets, - gridWidgets: viewModel.gridWidgets - ) - .paddingStyle(.all, .standard) - } + LearnerDashboardWidgetLayout( + fullWidthWidgets: viewModel.fullWidthWidgets, + gridWidgets: viewModel.gridWidgets + ) + .paddingStyle(.all, .standard) } } diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift index 26f09fe893..d5e04f584f 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift @@ -20,15 +20,16 @@ import Combine import Core import SwiftUI -protocol LearnerWidgetViewModel: AnyObject, Identifiable where ID == WidgetIdentifier { +protocol LearnerWidgetViewModel: AnyObject, Identifiable where ID == LearnerDashboardWidgetIdentifier { associatedtype ViewType: View + var id: LearnerDashboardWidgetIdentifier { get } + var config: WidgetConfig { get } - var id: WidgetIdentifier { get } var isFullWidth: Bool { get } var isEditable: Bool { get } - var state: InstUI.ScreenState { get } + var state: InstUI.ScreenState { get } func makeView() -> ViewType func refresh(ignoreCache: Bool) -> AnyPublisher } diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift index b901bf4033..13c4956b51 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift @@ -17,7 +17,7 @@ // struct WidgetConfig: Codable, Comparable, Identifiable { - let id: WidgetIdentifier + let id: LearnerDashboardWidgetIdentifier var order: Int var isVisible: Bool var settings: String? diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardCardStyle.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardCard.swift similarity index 54% rename from Student/Student/LearnerDashboard/Widgets/Common/View/DashboardCardStyle.swift rename to Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardCard.swift index ef5f07bb13..97efff2b89 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardCardStyle.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardCard.swift @@ -19,15 +19,32 @@ import SwiftUI import Core -struct DashboardCardStyle: ViewModifier { - func body(content: Content) -> some View { - content - .elevation(.cardLarge, background: .backgroundLightest) +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) } } -extension View { - func dashboardCardStyle() -> some View { - modifier(DashboardCardStyle()) +#if DEBUG + +#Preview { + LearnerDashboardCard { + Text(verbatim: "Hello") + .padding(50) } } + +#endif diff --git a/Student/Student/LearnerDashboard/Widgets/Common/View/TitledWidget.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardTitledWidget.swift similarity index 86% rename from Student/Student/LearnerDashboard/Widgets/Common/View/TitledWidget.swift rename to Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardTitledWidget.swift index c6054ebdb5..a7fd4dc03f 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/View/TitledWidget.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardTitledWidget.swift @@ -19,7 +19,7 @@ import Core import SwiftUI -struct TitledWidget: View { +struct LearnerDashboardTitledWidget: View { let title: String let content: Content @@ -44,10 +44,8 @@ struct TitledWidget: View { #if DEBUG #Preview { - TitledWidget("Weekly Summary") { - Text(verbatim: InstUI.PreviewData.loremIpsumLong(1)) - .paddingStyle(.standard) - .dashboardCardStyle() + LearnerDashboardTitledWidget("Weekly Summary") { + Text(verbatim: InstUI.PreviewData.loremIpsumShort) } } 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/DashboardWidgetLayout.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetLayout.swift similarity index 91% rename from Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift rename to Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetLayout.swift index 7c45b70bcc..861e68205e 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/View/DashboardWidgetLayout.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetLayout.swift @@ -19,7 +19,7 @@ import Core import SwiftUI -struct DashboardWidgetLayout: View { +struct LearnerDashboardWidgetLayout: View { let fullWidthWidgets: [any LearnerWidgetViewModel] let gridWidgets: [any LearnerWidgetViewModel] @State private var containerWidth: CGFloat = 0 @@ -29,6 +29,10 @@ struct DashboardWidgetLayout: View { fullWidthSection() gridSection(columnCount: columns(for: containerWidth)) } + // These are to properly animate widget size changes + // especially when one widget pushes another one + .animation(.smooth, value: fullWidthWidgets.map { $0.state }) + .animation(.smooth, value: gridWidgets.map { $0.state }) .onWidthChange { width in // Don't animate the first appearance withAnimation(containerWidth == 0 ? .none : .smooth) { @@ -96,7 +100,7 @@ struct DashboardWidgetLayout: View { _ = widget3.refresh(ignoreCache: false) return ScrollView { - DashboardWidgetLayout( + LearnerDashboardWidgetLayout( fullWidthWidgets: [fullWidthWidget], gridWidgets: [widget1, widget2, widget3] ) diff --git a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift index f717902004..a76c55c885 100644 --- a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift +++ b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift @@ -24,12 +24,13 @@ struct FullWidthWidgetView: View { var body: some View { if viewModel.state != .loading { - TitledWidget("Full Width Widget") { - Text(verbatim: InstUI.PreviewData.loremIpsumMedium) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .paddingStyle(.standard) - .dashboardCardStyle() + LearnerDashboardTitledWidget("Full Width Widget") { + LearnerDashboardCard { + Text(verbatim: InstUI.PreviewData.loremIpsumMedium) + .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 index 58afb80b37..5ff045fcf6 100644 --- a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/ViewModel/FullWidthWidgetViewModel.swift @@ -25,7 +25,7 @@ final class FullWidthWidgetViewModel: LearnerWidgetViewModel { typealias ViewType = FullWidthWidgetView let config: WidgetConfig - var id: WidgetIdentifier { config.id } + var id: LearnerDashboardWidgetIdentifier { config.id } let isFullWidth = true let isEditable = false var state: InstUI.ScreenState = .loading diff --git a/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift index 317e39dc8e..13a35c3527 100644 --- a/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift +++ b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetAssembly.swift @@ -24,8 +24,8 @@ enum LearnerDashboardWidgetAssembly { [ WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil), WidgetConfig(id: .widget1, order: 1, isVisible: true, settings: nil), - WidgetConfig(id: .widget2, order: 2, isVisible: true, settings: nil), - WidgetConfig(id: .widget3, order: 3, isVisible: true, settings: nil) + WidgetConfig(id: .widget3, order: 3, isVisible: true, settings: nil), + WidgetConfig(id: .widget2, order: 2, isVisible: true, settings: nil) ] } diff --git a/Student/Student/LearnerDashboard/Widgets/WidgetIdentifier.swift b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetIdentifier.swift similarity index 92% rename from Student/Student/LearnerDashboard/Widgets/WidgetIdentifier.swift rename to Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetIdentifier.swift index db99bf062a..f250f94326 100644 --- a/Student/Student/LearnerDashboard/Widgets/WidgetIdentifier.swift +++ b/Student/Student/LearnerDashboard/Widgets/LearnerDashboardWidgetIdentifier.swift @@ -16,7 +16,7 @@ // along with this program. If not, see . // -enum WidgetIdentifier: String, Codable, CaseIterable { +enum LearnerDashboardWidgetIdentifier: String, Codable, CaseIterable { case fullWidthWidget case widget1 case widget2 diff --git a/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift index 5e99c28987..3f04f63230 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift @@ -23,16 +23,17 @@ struct Widget1View: View { @State var viewModel: Widget1ViewModel var body: some View { - TitledWidget("Widget 1") { - VStack(alignment: .leading) { - Text(verbatim: InstUI.PreviewData.loremIpsumLong(1)) - .foregroundColor(.secondary) + LearnerDashboardTitledWidget("Widget 1") { + LearnerDashboardCard { + VStack(alignment: .leading) { + Text(verbatim: InstUI.PreviewData.loremIpsumLong(1)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) + .redacted(reason: viewModel.state == .data ? [] : .placeholder) + .animation(.smooth, value: viewModel.state) } - .frame(maxWidth: .infinity, alignment: .leading) - .paddingStyle(.standard) - .redacted(reason: viewModel.state == .data ? [] : .placeholder) - .dashboardCardStyle() - .animation(.smooth, value: viewModel.state) } } } diff --git a/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift index 5cb6ef97d0..cd1b44cc5c 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift @@ -25,7 +25,7 @@ final class Widget1ViewModel: LearnerWidgetViewModel { typealias ViewType = Widget1View let config: WidgetConfig - var id: WidgetIdentifier { config.id } + var id: LearnerDashboardWidgetIdentifier { config.id } let isFullWidth = false let isEditable = false var state: InstUI.ScreenState = .loading diff --git a/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift b/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift index 15615cc0e9..7d9ce8960a 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift @@ -23,14 +23,15 @@ struct Widget2View: View { @State var viewModel: Widget2ViewModel var body: some View { - TitledWidget("Widget 2") { - VStack(alignment: .leading) { - Text(verbatim: InstUI.PreviewData.loremIpsumLong(2)) - .foregroundColor(.secondary) + LearnerDashboardTitledWidget("Widget 2") { + LearnerDashboardCard { + VStack(alignment: .leading) { + Text(verbatim: InstUI.PreviewData.loremIpsumLong(2)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) } - .frame(maxWidth: .infinity, alignment: .leading) - .paddingStyle(.standard) - .dashboardCardStyle() } } } diff --git a/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift index 4fec3ac484..7caaf3f4f6 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift @@ -25,7 +25,7 @@ final class Widget2ViewModel: LearnerWidgetViewModel { typealias ViewType = Widget2View let config: WidgetConfig - var id: WidgetIdentifier { config.id } + var id: LearnerDashboardWidgetIdentifier { config.id } let isFullWidth = false let isEditable = false var state: InstUI.ScreenState = .data diff --git a/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift b/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift index 366484df66..ae7a18ec3e 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift @@ -23,14 +23,25 @@ struct Widget3View: View { @State var viewModel: Widget3ViewModel var body: some View { - TitledWidget("Widget 3") { - VStack(alignment: .leading) { - Text(verbatim: InstUI.PreviewData.loremIpsumLong(3)) - .foregroundColor(.secondary) + LearnerDashboardTitledWidget("Widget 3") { + LearnerDashboardCard { + switch viewModel.state { + case .empty, .data, .loading: + dataView + case .error: + LearnerDashboardWidgetErrorView(onRetry: viewModel.refresh) + } } - .frame(maxWidth: .infinity, alignment: .leading) - .paddingStyle(.standard) - .dashboardCardStyle() } + .animation(.smooth, value: viewModel.state) + } + + private var dataView: some View { + VStack(alignment: .leading) { + Text(verbatim: InstUI.PreviewData.loremIpsumLong(3)) + .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 index 6709f00033..b21c582216 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift @@ -25,10 +25,11 @@ final class Widget3ViewModel: LearnerWidgetViewModel { typealias ViewType = Widget3View let config: WidgetConfig - var id: WidgetIdentifier { config.id } + 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 @@ -38,7 +39,19 @@ final class Widget3ViewModel: LearnerWidgetViewModel { Widget3View(viewModel: self) } + func refresh() { + state = .loading + refresh(ignoreCache: true) + .sink() + .store(in: &subscriptions) + } + func refresh(ignoreCache: Bool) -> AnyPublisher { - Just(()).eraseToAnyPublisher() + Just(()) + .delay(for: 2, scheduler: RunLoop.main) + .map { [weak self] in + self?.state = .error + } + .eraseToAnyPublisher() } } diff --git a/Student/Student/Localizable.xcstrings b/Student/Student/Localizable.xcstrings index 3bf171dcf8..1aa9318501 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", @@ -100674,6 +100677,9 @@ } } } + }, + "Refresh" : { + }, "Refresh Button" : { "extractionState" : "manual", @@ -142749,6 +142755,9 @@ } } } + }, + "We weren't able to load this content.\nTry again, or come back later." : { + }, "Website Address" : { "localizations" : { From 22442defce031014051d80d108fd593112d728e3 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 15 Jan 2026 13:59:44 +0100 Subject: [PATCH 6/9] Layout updates refs: MBL-19528 builds: Student affects: Student release note: none test plan: --- .../View/LearnerDashboardScreen.swift | 5 ++- .../Common/Model/LearnerWidgetViewModel.swift | 22 +++++++++++ .../Widgets/Common/Model/WidgetConfig.swift | 1 + .../Common/Model/WidgetPlaceholderData.swift | 38 +++++++++++++++++++ .../View/LearnerDashboardWidgetLayout.swift | 6 +-- .../View/FullWidthWidgetView.swift | 2 +- .../Widgets/Widget1/View/Widget1View.swift | 5 ++- .../Widget1/ViewModel/Widget1ViewModel.swift | 25 ++++++++++++ .../Widgets/Widget2/View/Widget2View.swift | 20 +++++++--- .../Widget2/ViewModel/Widget2ViewModel.swift | 15 +++++++- .../Widgets/Widget3/View/Widget3View.swift | 21 +++------- .../Widget3/ViewModel/Widget3ViewModel.swift | 15 +------- 12 files changed, 132 insertions(+), 43 deletions(-) create mode 100644 Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetPlaceholderData.swift diff --git a/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift b/Student/Student/LearnerDashboard/Container/View/LearnerDashboardScreen.swift index a079caa57e..95128b2b0b 100644 --- a/Student/Student/LearnerDashboard/Container/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 diff --git a/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift index d5e04f584f..6b1ee75253 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/LearnerWidgetViewModel.swift @@ -25,11 +25,33 @@ protocol LearnerWidgetViewModel: AnyObject, Identifiable where ID == LearnerDash 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/WidgetConfig.swift b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift index 13c4956b51..cfd44e555c 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/Model/WidgetConfig.swift @@ -20,6 +20,7 @@ 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 { 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/LearnerDashboardWidgetLayout.swift b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetLayout.swift index 861e68205e..8460943a6a 100644 --- a/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetLayout.swift +++ b/Student/Student/LearnerDashboard/Widgets/Common/View/LearnerDashboardWidgetLayout.swift @@ -29,10 +29,8 @@ struct LearnerDashboardWidgetLayout: View { fullWidthSection() gridSection(columnCount: columns(for: containerWidth)) } - // These are to properly animate widget size changes - // especially when one widget pushes another one - .animation(.smooth, value: fullWidthWidgets.map { $0.state }) - .animation(.smooth, value: gridWidgets.map { $0.state }) + .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) { diff --git a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift index a76c55c885..ec8b8902d4 100644 --- a/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift +++ b/Student/Student/LearnerDashboard/Widgets/FullWidthWidget/View/FullWidthWidgetView.swift @@ -26,7 +26,7 @@ struct FullWidthWidgetView: View { if viewModel.state != .loading { LearnerDashboardTitledWidget("Full Width Widget") { LearnerDashboardCard { - Text(verbatim: InstUI.PreviewData.loremIpsumMedium) + Text(verbatim: WidgetPlaceholderData.medium) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .paddingStyle(.standard) diff --git a/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift index 3f04f63230..4454769c13 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget1/View/Widget1View.swift @@ -26,14 +26,15 @@ struct Widget1View: View { LearnerDashboardTitledWidget("Widget 1") { LearnerDashboardCard { VStack(alignment: .leading) { - Text(verbatim: InstUI.PreviewData.loremIpsumLong(1)) + 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.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 index cd1b44cc5c..f3d93bde3c 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget1/ViewModel/Widget1ViewModel.swift @@ -24,14 +24,39 @@ import SwiftUI 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 { diff --git a/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift b/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift index 7d9ce8960a..6d574ac1aa 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget2/View/Widget2View.swift @@ -25,13 +25,23 @@ struct Widget2View: View { var body: some View { LearnerDashboardTitledWidget("Widget 2") { LearnerDashboardCard { - VStack(alignment: .leading) { - Text(verbatim: InstUI.PreviewData.loremIpsumLong(2)) - .foregroundColor(.secondary) + switch viewModel.state { + case .empty, .data, .loading: + dataView + case .error: + LearnerDashboardWidgetErrorView(onRetry: viewModel.refresh) } - .frame(maxWidth: .infinity, alignment: .leading) - .paddingStyle(.standard) } } + .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 index 7caaf3f4f6..3c3878bdb4 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget2/ViewModel/Widget2ViewModel.swift @@ -29,6 +29,7 @@ final class Widget2ViewModel: LearnerWidgetViewModel { let isFullWidth = false let isEditable = false var state: InstUI.ScreenState = .data + private var subscriptions = Set() init(config: WidgetConfig) { self.config = config @@ -38,7 +39,19 @@ final class Widget2ViewModel: LearnerWidgetViewModel { Widget2View(viewModel: self) } + func refresh() { + state = .loading + refresh(ignoreCache: true) + .sink() + .store(in: &subscriptions) + } + func refresh(ignoreCache: Bool) -> AnyPublisher { - Just(()).eraseToAnyPublisher() + Just(()) + .delay(for: 2, scheduler: RunLoop.main) + .map { [weak self] in + self?.state = .error + } + .eraseToAnyPublisher() } } diff --git a/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift b/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift index ae7a18ec3e..30ae733256 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget3/View/Widget3View.swift @@ -20,28 +20,19 @@ import Core import SwiftUI struct Widget3View: View { + @State var viewModel: Widget3ViewModel var body: some View { LearnerDashboardTitledWidget("Widget 3") { LearnerDashboardCard { - switch viewModel.state { - case .empty, .data, .loading: - dataView - case .error: - LearnerDashboardWidgetErrorView(onRetry: viewModel.refresh) + VStack(alignment: .leading) { + Text(verbatim: WidgetPlaceholderData.long(2)) + .foregroundColor(.secondary) } + .frame(maxWidth: .infinity, alignment: .leading) + .paddingStyle(.standard) } } - .animation(.smooth, value: viewModel.state) - } - - private var dataView: some View { - VStack(alignment: .leading) { - Text(verbatim: InstUI.PreviewData.loremIpsumLong(3)) - .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 index b21c582216..34cc767ea4 100644 --- a/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift +++ b/Student/Student/LearnerDashboard/Widgets/Widget3/ViewModel/Widget3ViewModel.swift @@ -29,7 +29,6 @@ final class Widget3ViewModel: LearnerWidgetViewModel { let isFullWidth = false let isEditable = false var state: InstUI.ScreenState = .data - private var subscriptions = Set() init(config: WidgetConfig) { self.config = config @@ -39,19 +38,7 @@ final class Widget3ViewModel: LearnerWidgetViewModel { Widget3View(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() + Just(()).eraseToAnyPublisher() } } From 8e0097e8f1250d9753441489cbcf6540058161a5 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 15 Jan 2026 15:05:47 +0100 Subject: [PATCH 7/9] Add tests. --- .../LearnerDashboardInteractorMock.swift | 0 .../LearnerDashboardInteractorTests.swift | 170 ++++++++++++++++ .../LearnerDashboardViewModelTests.swift | 192 ++++++++++++++++++ .../LearnerDashboardInteractorLiveTests.swift | 31 --- .../LearnerDashboardViewModelTests.swift | 90 -------- .../SessionDefaults+WidgetConfigTests.swift | 101 +++++++++ .../Common/Model/WidgetConfigTests.swift | 104 ++++++++++ scripts/coverage/config.json | 5 + 8 files changed, 572 insertions(+), 121 deletions(-) rename Student/StudentUnitTests/LearnerDashboard/{ => Container}/Model/LearnerDashboardInteractorMock.swift (100%) create mode 100644 Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorTests.swift create mode 100644 Student/StudentUnitTests/LearnerDashboard/Container/ViewModel/LearnerDashboardViewModelTests.swift delete mode 100644 Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift delete mode 100644 Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift create mode 100644 Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/SessionDefaults+WidgetConfigTests.swift create mode 100644 Student/StudentUnitTests/LearnerDashboard/Widgets/Common/Model/WidgetConfigTests.swift diff --git a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift b/Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorMock.swift similarity index 100% rename from Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorMock.swift rename to Student/StudentUnitTests/LearnerDashboard/Container/Model/LearnerDashboardInteractorMock.swift 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/Model/LearnerDashboardInteractorLiveTests.swift b/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift deleted file mode 100644 index 8d4d273f9b..0000000000 --- a/Student/StudentUnitTests/LearnerDashboard/Model/LearnerDashboardInteractorLiveTests.swift +++ /dev/null @@ -1,31 +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 . -// - -@testable import Student -import XCTest - -final class LearnerDashboardInteractorLiveTests: StudentTestCase { - - func test_loadWidgets_shouldReturnWidgets() { - let testee = LearnerDashboardInteractorLive( - widgetViewModelFactory: LearnerDashboardWidgetAssembly.makeWidgetViewModel - ) - - XCTAssertFinish(testee.loadWidgets(), timeout: 3) - } -} diff --git a/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift b/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift deleted file mode 100644 index c8ec2937a4..0000000000 --- a/Student/StudentUnitTests/LearnerDashboard/ViewModel/LearnerDashboardViewModelTests.swift +++ /dev/null @@ -1,90 +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_loadWidgets_whenNoWidgets_shouldSetStateToEmpty() { - // WHEN - interactor.loadWidgetsPublisher.send((fullWidth: [], grid: [])) - scheduler.advance() - - // THEN - XCTAssertEqual(testee.state, .loading) - } - - func test_loadWidgets_whenWidgetsExist_shouldSetStateToData() { - let widget = FullWidthWidgetViewModel(config: WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil)) - - // WHEN - interactor.loadWidgetsPublisher.send((fullWidth: [widget], grid: [])) - scheduler.advance() - - // THEN - XCTAssertEqual(testee.state, .data) - XCTAssertEqual(testee.fullWidthWidgets.count, 1) - XCTAssertEqual(testee.gridWidgets.count, 0) - } - - func test_refresh_shouldComplete() { - let widget = FullWidthWidgetViewModel(config: WidgetConfig(id: .fullWidthWidget, order: 0, isVisible: true, settings: nil)) - let expectation = expectation(description: "Refresh should complete") - - interactor.loadWidgetsPublisher.send((fullWidth: [widget], grid: [])) - scheduler.advance() - - // WHEN - testee.refresh(ignoreCache: true) { - expectation.fulfill() - } - scheduler.advance(by: 2.1) - - // THEN - wait(for: [expectation], timeout: 3) - } -} 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 ba8a6f6ba7..37419f8454 100644 --- a/scripts/coverage/config.json +++ b/scripts/coverage/config.json @@ -36,6 +36,11 @@ "Student\/Submissions\/SubmissionButton\/SubmissionButtonPresenter.swift", "Student\/Student\/Assignments\/AssignmentDetails\/SubAssignmentsCard\/ViewModel\/StudentSubAssignmentsCardItem.swift", "Student\/Widgets\/", + "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", From f7da8c098a31b5f1b5090de3b70a1b82eefd77c8 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 15 Jan 2026 18:12:32 +0100 Subject: [PATCH 8/9] Add empty protocol file to code coverage exclusion list. --- scripts/coverage/config.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/coverage/config.json b/scripts/coverage/config.json index dfb4150ad2..eda63002f4 100644 --- a/scripts/coverage/config.json +++ b/scripts/coverage/config.json @@ -36,6 +36,7 @@ "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\/", From 4b245d9495970fd4dc6428bb460ecf0d42b1d8d2 Mon Sep 17 00:00:00 2001 From: Richard Harangozo Date: Fri, 23 Jan 2026 17:02:57 +0100 Subject: [PATCH 9/9] Mock pendo builds: Student [ignore-commit-lint] --- .../Analytics/PendoAnalyticsTracker.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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