Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,50 @@
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.
*/
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 }
Expand Down Expand Up @@ -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] }
Expand Down
6 changes: 3 additions & 3 deletions Core/Core/Resources/Assets.xcassets/Secrets/Contents.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
enum LearnerDashboardAssembly {

static func makeInteractor() -> LearnerDashboardInteractor {
LearnerDashboardInteractorLive()
LearnerDashboardInteractorLive(
widgetViewModelFactory: LearnerDashboardWidgetAssembly.makeWidgetViewModel
)
}

static func makeViewModel(interactor: LearnerDashboardInteractor) -> LearnerDashboardViewModel {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// This file is part of Canvas.
// Copyright (C) 2024-present Instructure, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//

import Combine
import Core
import Foundation

protocol LearnerDashboardInteractor {
func loadWidgets() -> AnyPublisher<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never>
}

final class LearnerDashboardInteractorLive: LearnerDashboardInteractor {
private let userDefaults: SessionDefaults
private let widgetViewModelFactory: (WidgetConfig) -> any LearnerWidgetViewModel

init(
userDefaults: SessionDefaults = AppEnvironment.shared.userDefaults ?? .fallback,
widgetViewModelFactory: @escaping (WidgetConfig) -> any LearnerWidgetViewModel
) {
self.userDefaults = userDefaults
self.widgetViewModelFactory = widgetViewModelFactory
}

func loadWidgets() -> AnyPublisher<(fullWidth: [any LearnerWidgetViewModel], grid: [any LearnerWidgetViewModel]), Never> {
Just(())
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.map { [userDefaults, widgetViewModelFactory] _ in
let configs: [WidgetConfig]
if let savedWidgets = userDefaults.learnerDashboardWidgetConfigs {
configs = savedWidgets.filter { $0.isVisible }.sorted()
} else {
configs = LearnerDashboardWidgetAssembly.makeDefaultWidgetConfigs()
}

let viewModels = configs.map { widgetViewModelFactory($0) }
let fullWidth = viewModels.filter { $0.isFullWidth }
let grid = viewModels.filter { !$0.isFullWidth }

return (fullWidth, grid)
}
.eraseToAnyPublisher()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -57,19 +60,19 @@ struct LearnerDashboardScreen: View {

@ViewBuilder
private var content: some View {
SwiftUI.EmptyView()
LearnerDashboardWidgetLayout(
fullWidthWidgets: viewModel.fullWidthWidgets,
gridWidgets: viewModel.gridWidgets
)
.paddingStyle(.all, .standard)
}
}

#if DEBUG

#Preview {
let controller = CoreHostingController(
LearnerDashboardScreen(
viewModel: LearnerDashboardViewModel(
interactor: LearnerDashboardInteractorLive()
)
)
LearnerDashboardAssembly.makeScreen()
)
CoreNavigationController(rootViewController: controller)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ import Observation
@Observable
final class LearnerDashboardViewModel {
private(set) var state: InstUI.ScreenState = .loading
private(set) var fullWidthWidgets: [any LearnerWidgetViewModel] = []
private(set) var gridWidgets: [any LearnerWidgetViewModel] = []

let screenConfig = InstUI.BaseScreenConfig(
refreshable: true,
showsScrollIndicators: false,
emptyPandaConfig: .init(
scene: SpacePanda(),
title: String(localized: "Welcome to Canvas!", bundle: .student),
Expand All @@ -48,17 +51,35 @@ final class LearnerDashboardViewModel {
) {
self.interactor = interactor
self.mainScheduler = mainScheduler
refresh(ignoreCache: false)

loadWidgets()
}

private func loadWidgets() {
interactor.loadWidgets()
.receive(on: mainScheduler)
.sink { [weak self] result in
guard let self else { return }
self.fullWidthWidgets = result.fullWidth
self.gridWidgets = result.grid
if !result.fullWidth.isEmpty || !result.grid.isEmpty {
self.state = .data
}
self.refresh(ignoreCache: false)
}
.store(in: &subscriptions)
}

func refresh(ignoreCache: Bool, completion: (() -> Void)? = nil) {
interactor.refresh(ignoreCache: ignoreCache)
let allWidgets = fullWidthWidgets + gridWidgets
let publishers = allWidgets.map { $0.refresh(ignoreCache: ignoreCache) }

Publishers.MergeMany(publishers)
.collect()
.receive(on: mainScheduler)
.sink { [weak self] _ in
guard let self else { return }
self.state = .empty
guard self != nil else { return }
completion?()
} receiveValue: { _ in
}
.store(in: &subscriptions)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// This file is part of Canvas.
// Copyright (C) 2024-present Instructure, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//

import Combine
import Core
import SwiftUI

protocol LearnerWidgetViewModel: AnyObject, Identifiable where ID == LearnerDashboardWidgetIdentifier {
associatedtype ViewType: View

var id: LearnerDashboardWidgetIdentifier { get }

/// User configurable widget settings.
var config: WidgetConfig { get }

/// Non-editable, widget specific property used for layouting.
/// Full width widgets are put at the top of the screen outside of the widget grid.
var isFullWidth: Bool { get }

var isEditable: Bool { get }

/// The state helps the dashboard screen to decide if the empty state should be shown or not.
var state: InstUI.ScreenState { get }

/// Used by the layout to detect when widget size might change and trigger smooth animations.
/// Override this property to include any size-affecting properties (e.g., text.count).
/// This is required because widget view models are stored in an array and SwiftUI can't observe
/// individual view model changes in the array.
/// Default implementation returns state.
var layoutIdentifier: AnyHashable { get }

func makeView() -> ViewType

/// When pull to refresh is performed on the dashboard each widget is asked to refresh their content.
func refresh(ignoreCache: Bool) -> AnyPublisher<Void, Never>
}

extension LearnerWidgetViewModel {
var layoutIdentifier: AnyHashable {
AnyHashable(state)
}
}
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
//

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
}
}
}
}
Loading