From bcccfb239f2bb373683f0eaf8238e875140718d8 Mon Sep 17 00:00:00 2001 From: Ahmed-Naguib93 Date: Mon, 20 Jan 2025 20:11:25 +0200 Subject: [PATCH 1/6] feat: convert ModuleSequenceViewController to swiftui [ignore-commit-lint] --- Core/Core/LTI/LTITools.swift | 2 +- Core/Core/Modules/MarkModuleItemDone.swift | 69 +++++++ Core/Core/Modules/MarkModuleItemRead.swift | 66 ++++++ .../ModuleItemDetailsViewController.swift | 2 +- Core/Core/Pages/GetPage.swift | 2 +- .../Horizon/Resources/Localizable.xcstrings | 24 +++ .../Features/Learn/LearnAssembly.swift | 2 +- .../Learn/View/CourseDetailsView.swift | 1 + .../Learn/View/CourseDetailsViewModel.swift | 12 +- .../Assembly/ModuleItemSequenceAssembly.swift | 100 +++++++++ .../Domain/ModuleItemSequenceInteractor.swift | 150 ++++++++++++++ .../Domain/ModuleItemStateInteractor.swift | 116 +++++++++++ .../View/ModuleNavBarButtons.swift | 51 +++++ .../ModuleNavBar/View/ModuleNavBarView.swift | 95 +++++++++ .../ExternalURLViewRepresentable.swift | 55 +++++ .../LTIViewRepresentable.swift | 51 +++++ .../ModuleItemViewRepresentable.swift | 39 ++++ .../View/ModuleItemLockedView.swift | 70 +++++++ .../View/ModuleItemSequenceErrorView.swift | 68 ++++++ .../View/ModuleItemSequenceView.swift | 142 +++++++++++++ .../View/ModuleItemSequenceViewModel.swift | 193 ++++++++++++++++++ .../View/ModuleItemSequenceViewState.swift | 37 ++++ .../Sources/Routing/HorizonRoutes.swift | 28 +-- .../Spinner/HorizonUI.Spinner.Size.swift | 2 +- .../Spinner/HorizonUI.Spinner.swift | 6 +- 25 files changed, 1357 insertions(+), 26 deletions(-) create mode 100644 Core/Core/Modules/MarkModuleItemDone.swift create mode 100644 Core/Core/Modules/MarkModuleItemRead.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/Assembly/ModuleItemSequenceAssembly.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemSequenceInteractor.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemStateInteractor.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarButtons.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarView.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/ExternalURLViewRepresentable.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/LTIViewRepresentable.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/ModuleItemViewRepresentable.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemLockedView.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceErrorView.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewState.swift diff --git a/Core/Core/LTI/LTITools.swift b/Core/Core/LTI/LTITools.swift index a83b8749fa..1e57e2d6e3 100644 --- a/Core/Core/LTI/LTITools.swift +++ b/Core/Core/LTI/LTITools.swift @@ -26,7 +26,7 @@ public class LTITools: NSObject { let env: AppEnvironment let context: Context let id: String? - let url: URL? + public let url: URL? let launchType: GetSessionlessLaunchURLRequest.LaunchType? let isQuizLTI: Bool? // This is optional because not all entry points provide this info let assignmentID: String? diff --git a/Core/Core/Modules/MarkModuleItemDone.swift b/Core/Core/Modules/MarkModuleItemDone.swift new file mode 100644 index 0000000000..e7d9372dcb --- /dev/null +++ b/Core/Core/Modules/MarkModuleItemDone.swift @@ -0,0 +1,69 @@ +// +// 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 Foundation +import CoreData + +public struct MarkModuleItemDone: APIUseCase { + public typealias Model = ModuleItem + + // MARK: - Dependencies + + public let courseID: String + public let moduleID: String + public let moduleItemID: String + public let done: Bool + + // MARK: - Init + + public init( + courseID: String, + moduleID: String, + moduleItemID: String, + done: Bool + ) { + self.courseID = courseID + self.moduleID = moduleID + self.moduleItemID = moduleItemID + self.done = done + } + public var cacheKey: String? + + public var request: PutMarkModuleItemDone { + PutMarkModuleItemDone( + courseID: courseID, + moduleID: moduleID, + moduleItemID: moduleItemID, + done: done + ) + } + + public func makeRequest(environment: AppEnvironment, completionHandler: @escaping RequestCallback) { + environment.api.makeRequest(request) { response, urlResponse, error in + if error == nil { + NotificationCenter.default.post(name: .moduleItemRequirementCompleted, object: nil) + } + completionHandler(response, urlResponse, error) + } + } + public func write( + response: APINoContent?, + urlResponse: URLResponse?, + to client: NSManagedObjectContext + ) { } +} diff --git a/Core/Core/Modules/MarkModuleItemRead.swift b/Core/Core/Modules/MarkModuleItemRead.swift new file mode 100644 index 0000000000..63a191e78a --- /dev/null +++ b/Core/Core/Modules/MarkModuleItemRead.swift @@ -0,0 +1,66 @@ +// +// 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 Foundation +import CoreData + +public struct MarkModuleItemRead: APIUseCase { + public typealias Model = ModuleItem + + // MARK: - Dependencies + + public let courseID: String + public let moduleID: String + public let moduleItemID: String + + // MARK: - Init + + public init( + courseID: String, + moduleID: String, + moduleItemID: String + ) { + self.courseID = courseID + self.moduleID = moduleID + self.moduleItemID = moduleItemID + } + + public var cacheKey: String? + public var request: PostMarkModuleItemRead { + PostMarkModuleItemRead( + courseID: courseID, + moduleID: moduleID, + moduleItemID: moduleItemID + ) + } + + public func makeRequest(environment: AppEnvironment, completionHandler: @escaping RequestCallback) { + environment.api.makeRequest(request) { response, urlResponse, error in + if error == nil { + NotificationCenter.default.post(name: .moduleItemRequirementCompleted, object: nil) + } + completionHandler(response, urlResponse, error) + } + } + + public func write( + response: APINoContent?, + urlResponse: URLResponse?, + to client: NSManagedObjectContext + ) { } +} diff --git a/Core/Core/Modules/ModuleItems/ModuleItemDetailsViewController.swift b/Core/Core/Modules/ModuleItems/ModuleItemDetailsViewController.swift index a3c7216013..cc8ab39a5f 100644 --- a/Core/Core/Modules/ModuleItems/ModuleItemDetailsViewController.swift +++ b/Core/Core/Modules/ModuleItems/ModuleItemDetailsViewController.swift @@ -210,6 +210,6 @@ public final class ModuleItemDetailsViewController: UIViewController, ColoredNav } extension Notification.Name { - static let moduleItemViewDidLoad = Notification.Name(rawValue: "com.instructure.core.notification.ModuleItemViewDidLoad") + public static let moduleItemViewDidLoad = Notification.Name(rawValue: "com.instructure.core.notification.ModuleItemViewDidLoad") public static let moduleItemRequirementCompleted = Notification.Name(rawValue: "com.instructure.core.notification.ModuleItemRequirementCompleted") } diff --git a/Core/Core/Pages/GetPage.swift b/Core/Core/Pages/GetPage.swift index a3fa8fe313..0e958c7cc6 100644 --- a/Core/Core/Pages/GetPage.swift +++ b/Core/Core/Pages/GetPage.swift @@ -27,7 +27,7 @@ public struct GetPage: UseCase { var isFrontPage: Bool { url == "front_page" } - init(context: Context, url: String) { + public init(context: Context, url: String) { self.context = context self.url = url.removingPercentEncoding ?? "" } diff --git a/Horizon/Horizon/Resources/Localizable.xcstrings b/Horizon/Horizon/Resources/Localizable.xcstrings index 5792887138..74666e0dc4 100644 --- a/Horizon/Horizon/Resources/Localizable.xcstrings +++ b/Horizon/Horizon/Resources/Localizable.xcstrings @@ -34,6 +34,9 @@ }, "Are you sure you want to proceed?" : { + }, + "Cancel" : { + }, "Check Answer" : { @@ -76,6 +79,15 @@ }, "Learn" : { + }, + "Locked" : { + + }, + "Mark as Done" : { + + }, + "Mark as Undone" : { + }, "My Progress" : { @@ -97,6 +109,9 @@ }, "of" : { + }, + "Ok" : { + }, "OK" : { @@ -115,6 +130,9 @@ }, "Regenerate" : { + }, + "Retry" : { + }, "Save" : { @@ -136,9 +154,15 @@ }, "Tell me more about this topic" : { + }, + "There was an error. please try again." : { + }, "Try Again" : { + }, + "Unsupported Item" : { + }, "Yes" : { diff --git a/Horizon/Horizon/Sources/Features/Learn/LearnAssembly.swift b/Horizon/Horizon/Sources/Features/Learn/LearnAssembly.swift index 45f6e10d44..d3f237451a 100644 --- a/Horizon/Horizon/Sources/Features/Learn/LearnAssembly.swift +++ b/Horizon/Horizon/Sources/Features/Learn/LearnAssembly.swift @@ -36,7 +36,7 @@ final class LearnAssembly { CoreHostingController( CourseDetailsView( viewModel: .init( - router: AppEnvironment.shared.router, + environment: AppEnvironment.shared, course: course ) ) diff --git a/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsView.swift b/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsView.swift index ccf1ad346c..beb6500bb6 100644 --- a/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsView.swift +++ b/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsView.swift @@ -38,6 +38,7 @@ struct CourseDetailsView: View { .padding(.top, .huiSpaces.primitives.small) .background(Color.huiColors.surface.pagePrimary) .onFirstAppear { selectedTabIndex = 0 } + .onAppear { viewModel.showTabBar() } } private var headerView: some View { diff --git a/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsViewModel.swift b/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsViewModel.swift index d4f0cf4119..1f42753c49 100644 --- a/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsViewModel.swift +++ b/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsViewModel.swift @@ -29,16 +29,16 @@ final class CourseDetailsViewModel: ObservableObject { // MARK: - Private - private let router: Router + private let environment: AppEnvironment private var subscriptions = Set() // MARK: - Init init( - router: Router, + environment: AppEnvironment, course: HCourse ) { - self.router = router + self.environment = environment self.course = course self.state = .data } @@ -46,6 +46,10 @@ final class CourseDetailsViewModel: ObservableObject { // MARK: - Inputs func moduleItemDidTap(url: URL, from: WeakViewController) { - router.route(to: url, from: from) + environment.router.route(to: url, from: from) + } + + func showTabBar() { + environment.tabBar(isVisible: true) } } diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Assembly/ModuleItemSequenceAssembly.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Assembly/ModuleItemSequenceAssembly.swift new file mode 100644 index 0000000000..bf2400ab80 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Assembly/ModuleItemSequenceAssembly.swift @@ -0,0 +1,100 @@ +// +// 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 Foundation +import Core + +enum ModuleItemSequenceAssembly { + static func makeItemSequenceView( + environment: AppEnvironment, + courseID: String, + assetType: GetModuleItemSequenceRequest.AssetType, + assetID: String, + url: URLComponents + ) -> UIViewController { + let interactor = ModuleItemSequenceInteractorLive( + courseID: courseID, + assetType: assetType, + environment: environment + ) + let stateInteractor = ModuleItemStateInteractorLive( + environment: environment, + courseID: courseID, + url: url, + assetType: assetType + ) + let viewModel = ModuleItemSequenceViewModel( + moduleItemInteractor: interactor, + moduleItemStateInteractor: stateInteractor, + assetType: assetType, + assetID: assetID + ) + let view = ModuleItemSequenceView(viewModel: viewModel) + environment.tabBar(isVisible: false) + return CoreHostingController(view) + } + + static func makeModuleNavBarView(isNextButtonEnabled: Bool, + isPreviousButtonEnabled: Bool, + didTapNext: @escaping () -> Void, + didTapPrevious: @escaping () -> Void) -> ModuleNavBarView { + let router = AppEnvironment.shared.router + return ModuleNavBarView( + router: router, + isNextButtonEnabled: isNextButtonEnabled, + isPreviousButtonEnabled: isPreviousButtonEnabled, + didTapNext: didTapNext, + didTapPrevious: didTapPrevious + ) + } + + static func makeErrorView(didTapRetry: @escaping () -> Void) -> ModuleItemSequenceErrorView { + ModuleItemSequenceErrorView(didTapRetry: didTapRetry) + } + + static func makeLockView(title: String, lockExplanation: String) -> ModuleItemLockedView { + ModuleItemLockedView(title: title, lockExplanation: lockExplanation) + } + + static func makeExternalURLView(environment: AppEnvironment, + name: String, + url: URL, + courseID: String?) -> ExternalURLViewRepresentable { + ExternalURLViewRepresentable( + environment: environment, + name: name, + url: url, + courseID: courseID + ) + + } + + static func makeLTIView(environment: AppEnvironment, + tools: LTITools, + name: String?) -> LTIViewRepresentable { + LTIViewRepresentable( + environment: environment, + tools: tools, + name: name + ) + } + + static func makeModuleItemView(viewController: UIViewController) -> ModuleItemViewRepresentable { + ModuleItemViewRepresentable(viewController: viewController) + } +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemSequenceInteractor.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemSequenceInteractor.swift new file mode 100644 index 0000000000..ebd3d75b48 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemSequenceInteractor.swift @@ -0,0 +1,150 @@ +// +// 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 Combine +import Core + +protocol ModuleItemSequenceInteractor { + func fetchModuleItems( + assetId: String, + moduleID: String?, + itemID: String? + ) -> AnyPublisher<(GetModuleItemSequence.Model?, GetModuleItem.Model?), Never> + + func markAsViewed(moduleID: String, + itemID: String + ) -> AnyPublisher<[MarkModuleItemRead.Model], Error> + + func markAsDone( + item: ModuleItem?, + moduleID: String, + itemID: String + ) -> AnyPublisher<[MarkModuleItemDone.Model], Error> + + func getCourseName() -> AnyPublisher + func setOfflineMode(assetID: String) -> String? +} + +final class ModuleItemSequenceInteractorLive: ModuleItemSequenceInteractor { + typealias AssetType = GetModuleItemSequenceRequest.AssetType + + // MARK: - Dependencies + + private let courseID: String + private let assetType: AssetType + private let environment: AppEnvironment + private let offlineModeInteractor: OfflineModeInteractor + + init( + courseID: String, + assetType: AssetType, + environment: AppEnvironment, + offlineModeInteractor: OfflineModeInteractor = OfflineModeAssembly.make() + ) { + self.courseID = courseID + self.assetType = assetType + self.environment = environment + self.offlineModeInteractor = offlineModeInteractor + } + + func fetchModuleItems( + assetId: String, + moduleID: String?, + itemID: String? + ) -> AnyPublisher<(GetModuleItemSequence.Model?, GetModuleItem.Model?), Never> { + let sequenceUseCase = GetModuleItemSequence(courseID: courseID, assetType: assetType, assetID: assetId) + let sequencePublisher = ReactiveStore(useCase: sequenceUseCase) + .getEntities() + .replaceError(with: []) + + return sequencePublisher + .flatMap { [weak self] moduleItemSequence -> AnyPublisher<([GetModuleItemSequence.Model], [GetModuleItem.Model]), Never> in + guard let self else { + return Just(([], [])).eraseToAnyPublisher() + } + + guard let firstSequence = moduleItemSequence.first else { + return Just(([], [])).eraseToAnyPublisher() + } + + let moduleId = moduleID ?? firstSequence.current?.moduleID + let itemId = itemID ?? firstSequence.current?.id + + if let moduleId, let itemId { + let getModuleItemUseCase = GetModuleItem(courseID: courseID, moduleID: moduleId, itemID: itemId) + let moduleItemPublisher = ReactiveStore(useCase: getModuleItemUseCase) + .getEntities() + .replaceError(with: []) + + return moduleItemPublisher + .map { moduleItems in (moduleItemSequence, moduleItems) } + .eraseToAnyPublisher() + } else { + return Just((moduleItemSequence, [])).eraseToAnyPublisher() + } + } + .removeDuplicates(by: { $0.0 == $1.0 && $0.1 == $1.1 }) + .receive(on: DispatchQueue.main) + .compactMap { (moduleItemSequence, moduleItems) -> (GetModuleItemSequence.Model?, GetModuleItem.Model?) in + (moduleItemSequence.first, moduleItems.first) + } + .eraseToAnyPublisher() + } + + func setOfflineMode(assetID: String) -> String? { + guard offlineModeInteractor.isOfflineModeEnabled(), Int(assetID) == nil else { return nil } + let moduleItems: [ModuleItem] = environment.database.viewContext.fetch(scope: .where(#keyPath(ModuleItem.pageId), equals: assetID)) + let firstItem = moduleItems.first + return firstItem?.id + } + + func markAsViewed(moduleID: String, itemID: String) -> AnyPublisher<[MarkModuleItemRead.Model], Error> { + let useCase = MarkModuleItemRead(courseID: courseID, moduleID: moduleID, moduleItemID: itemID) + return ReactiveStore(useCase: useCase) + .getEntities() + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + func markAsDone( + item: ModuleItem?, + moduleID: String, + itemID: String + ) -> AnyPublisher<[MarkModuleItemDone.Model], Error> { + + let useCase = MarkModuleItemDone( + courseID: courseID, + moduleID: moduleID, + moduleItemID: itemID, + done: item?.completed == false + ) + return ReactiveStore(useCase: useCase) + .getEntities() + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + func getCourseName() -> AnyPublisher { + ReactiveStore(useCase: GetCourse(courseID: courseID)) + .getEntities() + .replaceError(with: []) + .map { $0.first?.name ?? "" } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemStateInteractor.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemStateInteractor.swift new file mode 100644 index 0000000000..2d9a81541e --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemStateInteractor.swift @@ -0,0 +1,116 @@ +// +// 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 Combine +import Core + +protocol ModuleItemStateInteractor { + func getModuleItemState( + sequence: ModuleItemSequence?, + item: ModuleItem?, + moduleID: String?, + itemID: String? + ) -> ModuleItemSequenceViewState? +} + +final class ModuleItemStateInteractorLive: ModuleItemStateInteractor { + typealias AssetType = GetModuleItemSequenceRequest.AssetType + + // MARK: - Dependencies + + private let environment: AppEnvironment + private let courseID: String + private let url: URLComponents + private let assetType: AssetType + + // MARK: - Init + + init( + environment: AppEnvironment, + courseID: String, + url: URLComponents, + assetType: AssetType + ) { + self.environment = environment + self.courseID = courseID + self.url = url + self.assetType = assetType + } + + func getModuleItemState( + sequence: ModuleItemSequence?, + item: ModuleItem?, + moduleID: String?, + itemID: String? + ) -> ModuleItemSequenceViewState? { + guard let url = url.url else { return nil } + if sequence?.current != nil { + return getModuleItemDetailsState(item: item, moduleID: moduleID, itemID: itemID) + } else if assetType != .moduleItem, let match = environment.router.match(url.appendingOrigin("module_item_details")) { + return .moduleItem(controller: match, id: item?.url?.absoluteString ?? "") + } else { + return .externalURL( + url: url, + environment: environment, + name: String(localized: "Unsupported Item", bundle: .horizon), + courseID: courseID + ) + } + } + + private func getModuleItemDetailsState( + item: ModuleItem?, + moduleID: String?, + itemID: String? + ) -> ModuleItemSequenceViewState? { + guard let item else { return nil } + + let showLocked = item.visibleWhenLocked != true && item.lockedForUser == true + if showLocked { + return .locked(title: item.title, lockExplanation: item.lockExplanation ?? "") + } + + switch item.type { + case .externalURL(let url): + return .externalURL(url: url, environment: environment, name: item.title, courseID: item.courseID) + case let .externalTool(toolID, url): + let tools = LTITools( + env: environment, + context: .course(courseID), + id: toolID, + url: url, + launchType: .module_item, + isQuizLTI: item.isQuizLTI, + moduleID: moduleID, + moduleItemID: itemID + ) + return .externalTool(environment: environment, tools: tools, name: item.title) + default: + guard let url = item.url else { return nil } + let preparedURL = url.appendingOrigin("module_item_details") + let itemViewController = environment.router.match(preparedURL) + if let itemViewController, let routeTemplate = environment.router.template(for: preparedURL) { + RemoteLogger.shared.logBreadcrumb(route: routeTemplate, viewController: itemViewController) + } + if let itemViewController { + return .moduleItem(controller: itemViewController, id: url.absoluteString) + } + return nil + } + } +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarButtons.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarButtons.swift new file mode 100644 index 0000000000..53011aca69 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarButtons.swift @@ -0,0 +1,51 @@ +// +// 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 HorizonUI + +enum ModuleNavBarButtons: CaseIterable { + case previous + case tts + case assist + case notebook + case next + + static var contentButtons: [ModuleNavBarButtons] { + [ + .tts, + .assist, + .notebook + ] + } + + var image: Image { + switch self { + case .previous: + Image.huiIcons.arrowBack + case .tts: + Image.huiIcons.volumeUp + case .assist: + Image(.chatBot) + case .notebook: + Image.huiIcons.bookmark + case .next: + Image.huiIcons.arrowForward + } + } +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarView.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarView.swift new file mode 100644 index 0000000000..14f105ec39 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarView.swift @@ -0,0 +1,95 @@ +// +// 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 +import HorizonUI + +struct ModuleNavBarView: View { + // MARK: - Private Properties + + @Environment(\.viewController) private var controller + private let contentButtons = ModuleNavBarButtons.contentButtons + + // MARK: - Dependencies + + private let router: Router + private let isNextButtonEnabled: Bool + private let isPreviousButtonEnabled: Bool + private let didTapNext: () -> Void + private let didTapPrevious: () -> Void + + init( + router: Router, + isNextButtonEnabled: Bool, + isPreviousButtonEnabled: Bool, + didTapNext: @escaping () -> Void, + didTapPrevious: @escaping () -> Void + ) { + self.router = router + self.isNextButtonEnabled = isNextButtonEnabled + self.isPreviousButtonEnabled = isPreviousButtonEnabled + self.didTapNext = didTapNext + self.didTapPrevious = didTapPrevious + } + + var body: some View { + HStack(spacing: 0) { + Button { + didTapPrevious() + } label: { + iconView(type: .previous) + } + .disableWithOpacity(!isPreviousButtonEnabled) + + Spacer() + HStack(spacing: 8) { + ForEach(contentButtons, id: \.self) { button in + buttonView(type: button) + } + } + Spacer() + Button { + didTapNext() + } label: { + iconView(type: .next) + } + .disableWithOpacity(!isNextButtonEnabled) + } + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color.huiColors.surface.pagePrimary) + } + + private func buttonView(type: ModuleNavBarButtons) -> some View { + Button { + router.route(to: "/tutor", from: controller, options: .modal()) + } label: { + iconView(type: type) + } + } + + private func iconView(type: ModuleNavBarButtons) -> some View { + Circle() + .fill(Color.disabledGray.opacity(0.2)) + .frame(width: 50, height: 50) + .overlay( + type.image.foregroundStyle(Color.textDarkest) + ) + } +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/ExternalURLViewRepresentable.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/ExternalURLViewRepresentable.swift new file mode 100644 index 0000000000..c7bb7a8639 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/ExternalURLViewRepresentable.swift @@ -0,0 +1,55 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import SwiftUI +import Core + +struct ExternalURLViewRepresentable: UIViewControllerRepresentable { + // MARK: - Dependencies + + private let environment: AppEnvironment + private let name: String + private let url: URL + private let courseID: String? + + init( + environment: AppEnvironment, + name: String, + url: URL, + courseID: String? + ) { + self.environment = environment + self.name = name + self.url = url + self.courseID = courseID + } + + func makeUIViewController(context: Self.Context) -> ExternalURLViewController { + ExternalURLViewController.create( + env: environment, + name: name, + url: url, + courseID: courseID + ) + } + + func updateUIViewController( + _ uiViewController: ExternalURLViewController, + context: Self.Context + ) { } +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/LTIViewRepresentable.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/LTIViewRepresentable.swift new file mode 100644 index 0000000000..2bda34188c --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/LTIViewRepresentable.swift @@ -0,0 +1,51 @@ +// +// 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 LTIViewRepresentable: UIViewControllerRepresentable { + // MARK: - Dependencies + + private let environment: AppEnvironment + private let tools: LTITools + private let name: String? + + init( + environment: AppEnvironment, + tools: LTITools, + name: String? + ) { + self.environment = environment + self.tools = tools + self.name = name + } + + func makeUIViewController(context: Self.Context) -> LTIViewController { + LTIViewController.create( + env: environment, + tools: tools, + name: name + ) + } + + func updateUIViewController( + _ uiViewController: LTIViewController, + context: Self.Context + ) { } +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/ModuleItemViewRepresentable.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/ModuleItemViewRepresentable.swift new file mode 100644 index 0000000000..2a8cfea0df --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/ModuleItemViewRepresentable.swift @@ -0,0 +1,39 @@ +// +// 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 ModuleItemViewRepresentable: UIViewControllerRepresentable { + // MARK: - Dependencies + + private let viewController: UIViewController + + init(viewController: UIViewController) { + self.viewController = viewController + } + + func makeUIViewController(context: Self.Context) -> UIViewController { + viewController + } + + func updateUIViewController( + _ uiViewController: UIViewController, + context: Self.Context + ) { } +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemLockedView.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemLockedView.swift new file mode 100644 index 0000000000..20f11d8dab --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemLockedView.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 HorizonUI +import Core + +struct ModuleItemLockedView: View { + // MARK: - Dependencies + + private let title: String + private let lockExplanation: String + + init( + title: String, + lockExplanation: String + ) { + self.title = title + self.lockExplanation = lockExplanation + } + + var body: some View { + VStack { + Text(title) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(Color.huiColors.text.body) + .huiTypography(.h2) + + Spacer() + + Image("PandaLocked", bundle: .core) + .resizable() + .scaledToFit() + .frame(width: 240, height: 128) + + Text("Locked", bundle: .horizon) + .foregroundStyle(Color.huiColors.text.body) + .huiTypography(.h3) + .padding(.top, .huiSpaces.primitives.medium) + + WebView(html: "

\(lockExplanation)

") + .frameToFit() + + Spacer() + } + .padding(.huiSpaces.primitives.mediumSmall) + } +} + +#Preview { + ModuleItemLockedView( + title: "Locked Title", + lockExplanation: "

The content is locked because it is not yet available.

" + ) +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceErrorView.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceErrorView.swift new file mode 100644 index 0000000000..39dfd3d9c5 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceErrorView.swift @@ -0,0 +1,68 @@ +// +// 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 HorizonUI + +struct ModuleItemSequenceErrorView: View { + + // MARK: - Dependencies + + private let didTapRetry: () -> Void + + init(didTapRetry: @escaping () -> Void) { + self.didTapRetry = didTapRetry + } + + var body: some View { + VStack(spacing: 20) { + Spacer() + Image.huiIcons.error + .resizable() + .frame(width: 30, height: 30) + .foregroundStyle(Color.huiColors.icon.error) + + Text("There was an error. please try again.", bundle: .horizon) + .foregroundStyle(Color.huiColors.primitives.grey45) + .huiTypography(.h3) + + Button { + didTapRetry() + } label: { + retryButtonLabel + } + Spacer() + } + } + + private var retryButtonLabel: some View { + Text("Retry", bundle: .horizon) + .foregroundStyle(Color.huiColors.primitives.grey24) + .huiTypography(.labelLargeBold) + .padding(.huiSpaces.primitives.smallMedium) + .frame(width: 120, height: 55) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.huiColors.primitives.grey24, lineWidth: 2) + ) + } +} + +#Preview { + ModuleItemSequenceErrorView {} +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift new file mode 100644 index 0000000000..8012cbecd0 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift @@ -0,0 +1,142 @@ +// +// 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 HorizonUI +import Core + +public struct ModuleItemSequenceView: View { + @State var viewModel: ModuleItemSequenceViewModel + @State private var isShowMakeAsDoneSheet = false + + public var body: some View { + ZStack(alignment: .center) { + VStack(spacing: .zero) { + mainContent + .offset(x: viewModel.offsetX) + } + .frame(maxWidth: .infinity) + .safeAreaInset(edge: .bottom, spacing: .zero) { moduleNavBarView } + if viewModel.isLoaderVisible { + HorizonUI.Spinner(size: .medium, showBackground: true) + } + } + .confirmationDialog("", isPresented: $isShowMakeAsDoneSheet, titleVisibility: .hidden) { + makeAsDoneSheetButtons + } + .alert(String(localized: "Error", bundle: .core), isPresented: $viewModel.isShowErrorAlert) { + Button(String(localized: "Ok", bundle: .core), role: .cancel) { } + } message: { + Text(viewModel.errorMessage) + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { makeAsDoneButton } + ToolbarItem(placement: .principal) { + VStack(spacing: .huiSpaces.primitives.xxxSmall) { + Text(viewModel.item?.title ?? "") + .foregroundStyle(Color.huiColors.text.body) + .huiTypography(.labelLargeBold) + Text(viewModel.courseName) + .foregroundStyle(Color.huiColors.primitives.grey24) + .huiTypography(.labelSmall) + } + } + } + } + + @ViewBuilder + private var makeAsDoneSheetButtons: some View { + let title = viewModel.item?.completed == true + ? String(localized: "Mark as Undone", bundle: .core) + : String(localized: "Mark as Done", bundle: .core) + Button(title) { viewModel.markAsDone()} + Button(String(localized: "Cancel", bundle: .core), role: .cancel) {} + } + + @ViewBuilder + private var makeAsDoneButton: some View { + if viewModel.item?.completionRequirementType == .must_mark_done { + Button(action: { + isShowMakeAsDoneSheet = true + }) { + Image.huiIcons.moreHoriz + .foregroundStyle(Color.huiColors.text.body) + } + } + } + + private func goNext() { + withAnimation { + viewModel.offsetX = -UIScreen.main.bounds.width * 2 + } completion: { + viewModel.goNext() + } + } + + private func goPervious() { + withAnimation { + viewModel.offsetX = UIScreen.main.bounds.width * 2 + } completion: { + viewModel.goPervious() + } + } + + @ViewBuilder + private var mainContent: some View { + if let state = viewModel.viewState { + switch state { + case .externalURL(url: let url, environment: let environment, name: let name, courseID: let courseID): + ModuleItemSequenceAssembly.makeExternalURLView( + environment: environment, + name: name, + url: url, + courseID: courseID + ) + .id(url.absoluteString) + case .externalTool(environment: let environment, tools: let tools, name: let name): + ModuleItemSequenceAssembly.makeLTIView( + environment: environment, + tools: tools, + name: name + ) + .id(tools.url?.absoluteString) + case .moduleItem(controller: let controller, let id): + ModuleItemSequenceAssembly.makeModuleItemView(viewController: controller) + .id(id) + case .error: + ModuleItemSequenceAssembly.makeErrorView { + viewModel.retry() + } + case .locked(title: let title, lockExplanation: let lockExplanation): + ModuleItemSequenceAssembly.makeLockView(title: title, lockExplanation: lockExplanation) + } + } + } + + private var moduleNavBarView: some View { + ModuleItemSequenceAssembly.makeModuleNavBarView( + isNextButtonEnabled: viewModel.isNextButtonEnabled, + isPreviousButtonEnabled: viewModel.isPreviousButtonEnabled + ) { + goNext() + } didTapPrevious: { + goPervious() + } + .frame(height: 56) + } +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift new file mode 100644 index 0000000000..bf92e585eb --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift @@ -0,0 +1,193 @@ +// +// 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 Combine +import Core +import Observation + +@Observable +final class ModuleItemSequenceViewModel { + typealias AssetType = GetModuleItemSequenceRequest.AssetType + // MARK: - Output + + private(set) var viewState: ModuleItemSequenceViewState? + private(set) var isNextButtonEnabled: Bool = true + private(set) var isPreviousButtonEnabled: Bool = true + private(set) var isLoaderVisible: Bool = false + private(set) var errorMessage = "" + private(set) var courseName = "" + private(set) var item: ModuleItem? + + // MARK: - Input / Output + + var offsetX: CGFloat = 0 + var isShowErrorAlert: Bool = false + + // MARK: - Private Properties + private var moduleID: String? + private var itemID: String? + private var subscriptions = Set() + private var sequence: ModuleItemSequence? + + // MARK: - Dependencies + + private let moduleItemInteractor: ModuleItemSequenceInteractor + private let moduleItemStateInteractor: ModuleItemStateInteractor + private let assetType: AssetType + private let assetID: String + + // MARK: - Init + + deinit { + NotificationCenter.default.removeObserver(self) + } + + init( + moduleItemInteractor: ModuleItemSequenceInteractor, + moduleItemStateInteractor: ModuleItemStateInteractor, + assetType: AssetType, + assetID: String + ) { + self.moduleItemInteractor = moduleItemInteractor + self.moduleItemStateInteractor = moduleItemStateInteractor + self.assetType = assetType + self.assetID = assetID + + offlineMode() + + fetchModuleItemSequence(assetId: assetID) + + moduleItemInteractor.getCourseName() + .sink { [weak self] name in + self?.courseName = name + } + .store(in: &subscriptions) + } + + private func offlineMode() { + if let id = moduleItemInteractor.setOfflineMode(assetID: assetID) { + fetchModuleItemSequence(assetId: id) + } + } + private func fetchModuleItemSequence(assetId: String) { + moduleItemInteractor.fetchModuleItems( + assetId: assetId, + moduleID: moduleID, + itemID: itemID + ) + .sink { [weak self] result in + let firstSequence = result.0 + self?.sequence = firstSequence + self?.isNextButtonEnabled = firstSequence?.next != nil + self?.isPreviousButtonEnabled = firstSequence?.prev != nil + self?.item = result.1 + self?.updateModuleItemDetails() + } + .store(in: &subscriptions) + } + + private func updateModuleItemDetails() { + moduleID = item?.moduleID + itemID = item?.id + var currentState = getCurrentState(item: item) + + if currentState == nil { + currentState = .error + } + viewState = currentState + offsetX = 0 + } + + private func getCurrentState(item: ModuleItem?) -> ModuleItemSequenceViewState? { + let state = moduleItemStateInteractor.getModuleItemState( + sequence: sequence, + item: item, + moduleID: moduleID, + itemID: itemID + ) + if state?.isModuleItem == true { + markAsViewed() + } + return state + } + + private func markAsViewed() { + guard let moduleID, let itemID, let item else { + return + } + + NotificationCenter.default.post(name: .moduleItemViewDidLoad, object: nil, userInfo: [ + "moduleID": moduleID, + "itemID": itemID + ]) + + guard item.completionRequirementType == .must_view, + item.completed == false, + item.lockedForUser == false else { + return + } + moduleItemInteractor + .markAsViewed(moduleID: moduleID, itemID: itemID) + .sink() + .store(in: &subscriptions) + } + + func markAsDone() { + guard let moduleID, let itemID else { + return + } + isLoaderVisible = true + moduleItemInteractor.markAsDone( + item: item, + moduleID: moduleID, + itemID: itemID + ) + .sink { [weak self] completion in + if case .failure(let error) = completion { + self?.isShowErrorAlert = true + self?.errorMessage = error.localizedDescription + } + self?.isLoaderVisible = false + } receiveValue: { _ in} + .store(in: &subscriptions) + } + + func retry() { + fetchModuleItemSequence(assetId: item?.id ?? assetID) + } + + func goNext() { + guard let next = sequence?.next else { return } + moduleID = next.moduleID + itemID = next.id + update(item: next) + } + + func goPervious() { + guard let prev = sequence?.prev else { return } + moduleID = prev.moduleID + itemID = prev.id + update(item: prev) + } + + private func update(item: ModuleItemSequenceNode) { + moduleID = item.moduleID + itemID = item.id + fetchModuleItemSequence(assetId: item.id) + } +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewState.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewState.swift new file mode 100644 index 0000000000..f95a1945d6 --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewState.swift @@ -0,0 +1,37 @@ +// +// 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 Foundation +import Core + +enum ModuleItemSequenceViewState { + case externalURL(url: URL, environment: AppEnvironment, name: String, courseID: String) + case externalTool(environment: AppEnvironment, tools: LTITools, name: String?) + case moduleItem(controller: UIViewController, id: String) + case error + case locked(title: String, lockExplanation: String) + + var isModuleItem: Bool { + switch self { + case .moduleItem: + return true + default: + return false + } + } +} diff --git a/Horizon/Horizon/Sources/Routing/HorizonRoutes.swift b/Horizon/Horizon/Sources/Routing/HorizonRoutes.swift index 557dc96b3a..53a08a89d8 100644 --- a/Horizon/Horizon/Sources/Routing/HorizonRoutes.swift +++ b/Horizon/Horizon/Sources/Routing/HorizonRoutes.swift @@ -51,8 +51,8 @@ enum HorizonRoutes { [ RouteHandler("/courses/:courseID/module_item_redirect/:itemID") { url, params, _, env in guard let courseID = params["courseID"], let itemID = params["itemID"] else { return nil } - return ModuleItemSequenceViewController.create( - env: env, + return ModuleItemSequenceAssembly.makeItemSequenceView( + environment: env, courseID: courseID, assetType: .moduleItem, assetID: itemID, @@ -61,8 +61,8 @@ enum HorizonRoutes { }, RouteHandler("/courses/:courseID/modules/items/:itemID") { url, params, _, env in guard let courseID = params["courseID"], let itemID = params["itemID"] else { return nil } - return ModuleItemSequenceViewController.create( - env: env, + return ModuleItemSequenceAssembly.makeItemSequenceView( + environment: env, courseID: courseID, assetType: .moduleItem, assetID: itemID, @@ -71,8 +71,8 @@ enum HorizonRoutes { }, RouteHandler("/courses/:courseID/modules/:moduleID/items/:itemID") { url, params, _, env in guard let courseID = params["courseID"], let itemID = params["itemID"] else { return nil } - return ModuleItemSequenceViewController.create( - env: env, + return ModuleItemSequenceAssembly.makeItemSequenceView( + environment: env, courseID: courseID, assetType: .moduleItem, assetID: itemID, @@ -132,8 +132,8 @@ enum HorizonRoutes { RouteHandler("/courses/:courseID/quizzes/:quizID") { url, params, _, env in guard let courseID = params["courseID"], let quizID = params["quizID"] else { return nil } if !url.originIsModuleItemDetails { - return ModuleItemSequenceViewController.create( - env: env, + return ModuleItemSequenceAssembly.makeItemSequenceView( + environment: env, courseID: courseID, assetType: .quiz, assetID: quizID, @@ -153,8 +153,8 @@ enum HorizonRoutes { return SyllabusTabViewController.create(courseID: ID.expandTildeID(courseID)) } if !url.originIsModuleItemDetails { - return ModuleItemSequenceViewController.create( - env: env, + return ModuleItemSequenceAssembly.makeItemSequenceView( + environment: env, courseID: ID.expandTildeID(courseID), assetType: .assignment, assetID: ID.expandTildeID(assignmentID), @@ -265,8 +265,8 @@ extension HorizonRoutes { } let assignmentID = url.queryItems?.first(where: { $0.name == "assignmentID" })?.value if !url.originIsModuleItemDetails, !url.skipModuleItemSequence, let context = context, context.contextType == .course { - return ModuleItemSequenceViewController.create( - env: environment, + return ModuleItemSequenceAssembly.makeItemSequenceView( + environment: environment, courseID: context.id, assetType: .file, assetID: fileID, @@ -289,8 +289,8 @@ extension HorizonRoutes { ) -> UIViewController? { guard let context = Context(path: url.path), let pageURL = params["url"] else { return nil } if !url.originIsModuleItemDetails, context.contextType == .course { - return ModuleItemSequenceViewController.create( - env: environment, + return ModuleItemSequenceAssembly.makeItemSequenceView( + environment: environment, courseID: context.id, assetType: .page, assetID: pageURL, diff --git a/packages/HorizonUI/Sources/HorizonUI/Sources/Components/Spinner/HorizonUI.Spinner.Size.swift b/packages/HorizonUI/Sources/HorizonUI/Sources/Components/Spinner/HorizonUI.Spinner.Size.swift index 10bb03ddc8..40836d81d8 100644 --- a/packages/HorizonUI/Sources/HorizonUI/Sources/Components/Spinner/HorizonUI.Spinner.Size.swift +++ b/packages/HorizonUI/Sources/HorizonUI/Sources/Components/Spinner/HorizonUI.Spinner.Size.swift @@ -18,7 +18,7 @@ import SwiftUI -extension HorizonUI.Spinner { +public extension HorizonUI.Spinner { enum Size { case xSmall case small diff --git a/packages/HorizonUI/Sources/HorizonUI/Sources/Components/Spinner/HorizonUI.Spinner.swift b/packages/HorizonUI/Sources/HorizonUI/Sources/Components/Spinner/HorizonUI.Spinner.swift index 9d59d53c42..9d95081d08 100644 --- a/packages/HorizonUI/Sources/HorizonUI/Sources/Components/Spinner/HorizonUI.Spinner.swift +++ b/packages/HorizonUI/Sources/HorizonUI/Sources/Components/Spinner/HorizonUI.Spinner.swift @@ -19,7 +19,7 @@ import Combine import SwiftUI -extension HorizonUI { +public extension HorizonUI { struct Spinner: View { // MARK: - Dependencies @@ -35,12 +35,12 @@ extension HorizonUI { // MARK: - Init - init(size: HorizonUI.Spinner.Size = .medium, showBackground: Bool = false) { + public init(size: HorizonUI.Spinner.Size = .medium, showBackground: Bool = false) { self.size = size self.showBackground = showBackground } - var body: some View { + public var body: some View { ZStack { if self.showBackground { SpinnerCircle( From 40513702c08312bc9426d33b9a1cdab5f821bd4d Mon Sep 17 00:00:00 2001 From: Ahmed-Naguib93 Date: Tue, 21 Jan 2025 21:38:20 +0200 Subject: [PATCH 2/6] Remove horizon edition in module items controller --- ...oduleItemSequenceViewController.storyboard | 15 +++- .../ModuleItemSequenceViewController.swift | 81 +++-------------- .../ModuleBottomNavBarViewModel.swift | 54 ------------ .../ModuleNavBarButtonType.swift | 51 ----------- .../ModuleBottomNavBar/ModuleNavBarView.swift | 86 ------------------- 5 files changed, 23 insertions(+), 264 deletions(-) delete mode 100644 Core/Core/SwiftUIViews/ModuleBottomNavBar/ModuleBottomNavBarViewModel.swift delete mode 100644 Core/Core/SwiftUIViews/ModuleBottomNavBar/ModuleNavBarButtonType.swift delete mode 100644 Core/Core/SwiftUIViews/ModuleBottomNavBar/ModuleNavBarView.swift diff --git a/Core/Core/Modules/ModuleItems/ModuleItemSequenceViewController.storyboard b/Core/Core/Modules/ModuleItems/ModuleItemSequenceViewController.storyboard index 413c1566ac..096427d2d1 100644 --- a/Core/Core/Modules/ModuleItems/ModuleItemSequenceViewController.storyboard +++ b/Core/Core/Modules/ModuleItems/ModuleItemSequenceViewController.storyboard @@ -1,9 +1,9 @@ - + - + @@ -79,7 +79,6 @@ - @@ -88,9 +87,17 @@ + + + + + + + + - + diff --git a/Core/Core/Modules/ModuleItems/ModuleItemSequenceViewController.swift b/Core/Core/Modules/ModuleItems/ModuleItemSequenceViewController.swift index d898cf1f4c..95d50610ee 100644 --- a/Core/Core/Modules/ModuleItems/ModuleItemSequenceViewController.swift +++ b/Core/Core/Modules/ModuleItems/ModuleItemSequenceViewController.swift @@ -17,7 +17,6 @@ // import Foundation -import SwiftUI import UIKit public final class ModuleItemSequenceViewController: UIViewController { @@ -28,14 +27,12 @@ public final class ModuleItemSequenceViewController: UIViewController { @IBOutlet weak var buttonsHeightConstraint: NSLayoutConstraint! @IBOutlet weak var previousButton: UIButton! @IBOutlet weak var nextButton: UIButton! - @IBOutlet weak var pagesContainerBottomConstraint: NSLayoutConstraint! /// These should get set only once in viewDidLoad private var leftBarButtonItems: [UIBarButtonItem]? private var rightBarButtonItems: [UIBarButtonItem]? private var env: AppEnvironment = .defaultValue - private lazy var isHorizon = env.app == .horizon private var courseID: String! private var assetType: AssetType! private var assetID: String! @@ -48,20 +45,8 @@ public final class ModuleItemSequenceViewController: UIViewController { private lazy var store = env.subscribe(GetModuleItemSequence(courseID: courseID, assetType: assetType, assetID: assetID)) { [weak self] in self?.update(embed: true) } - private var sequence: ModuleItemSequence? { store.first } - private lazy var moduleNavigationViewModel = ModuleBottomNavBarViewModel( - didTapPreviousButton: { [weak self] in - self?.goPrevious() - }, - didTapNextButton: { [weak self] in - self?.goNext() - }, - router: AppEnvironment.shared.router, - hostingViewController: self - ) - public static func create( env: AppEnvironment, courseID: String, @@ -80,7 +65,7 @@ public final class ModuleItemSequenceViewController: UIViewController { return controller } - override public func viewDidLoad() { + public override func viewDidLoad() { super.viewDidLoad() leftBarButtonItems = navigationItem.leftBarButtonItems rightBarButtonItems = navigationItem.rightBarButtonItems @@ -89,11 +74,11 @@ public final class ModuleItemSequenceViewController: UIViewController { pages.scrollView.isScrollEnabled = false embed(pages, in: pagesContainer) - if isHorizon { - setupModuleNavigationBarForHorizon() - } else { - setupModuleNavigationBarForCanvas() - } + // places the next arrow on the opposite side + let transform = CGAffineTransform(scaleX: -1, y: 1) + nextButton.transform = transform + nextButton.titleLabel?.transform = transform + nextButton.imageView?.transform = transform // Sometimes module links within Pages are referenced by their pageId ("/pages/my-module") instead of their id. // When downloading module item sequences for offline usage, we always download with the id field so we need to @@ -110,42 +95,6 @@ public final class ModuleItemSequenceViewController: UIViewController { store.refresh(force: true) } - override public func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - guard isHorizon else { return } - env.tabBar(isVisible: true) - } - - private func setupModuleNavigationBarForHorizon() { - AppEnvironment.shared.tabBar(isVisible: false) - pagesContainerBottomConstraint.isActive = false - buttonsContainer.removeFromSuperview() - - let hostingVC = UIHostingController(rootView: ModuleNavBarView(viewModel: moduleNavigationViewModel)) - - hostingVC.view.translatesAutoresizingMaskIntoConstraints = false - hostingVC.view.backgroundColor = UIColor(hexString: "#FBF5ED") - addChild(hostingVC) - view.addSubview(hostingVC.view) - view.backgroundColor = UIColor(hexString: "#FBF5ED") - NSLayoutConstraint.activate([ - hostingVC.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), - hostingVC.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - hostingVC.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - pagesContainer.bottomAnchor.constraint(equalTo: hostingVC.view.topAnchor) - - ]) - hostingVC.didMove(toParent: self) - } - - private func setupModuleNavigationBarForCanvas() { - // places the next arrow on the opposite side - let transform = CGAffineTransform(scaleX: -1, y: 1) - nextButton.transform = transform - nextButton.titleLabel?.transform = transform - nextButton.imageView?.transform = transform - } - private func update(embed: Bool) { if store.requested, store.pending { return @@ -175,18 +124,12 @@ public final class ModuleItemSequenceViewController: UIViewController { } private func showSequenceButtons(prev: Bool, next: Bool) { - if isHorizon { - moduleNavigationViewModel.isPreviousButtonEnabled = prev - moduleNavigationViewModel.isNextButtonEnabled = next - } else { - let show = prev || next - buttonsContainer.isHidden = show == false - buttonsHeightConstraint.constant = show ? 56 : 0 - previousButton.isHidden = prev == false - nextButton.isHidden = next == false - } - - view.layoutIfNeeded() + let show = prev || next + self.buttonsContainer.isHidden = show == false + self.buttonsHeightConstraint.constant = show ? 56 : 0 + previousButton.isHidden = prev == false + nextButton.isHidden = next == false + self.view.layoutIfNeeded() } private func show(item: ModuleItemSequenceNode, direction: PagesViewController.Direction? = nil) { diff --git a/Core/Core/SwiftUIViews/ModuleBottomNavBar/ModuleBottomNavBarViewModel.swift b/Core/Core/SwiftUIViews/ModuleBottomNavBar/ModuleBottomNavBarViewModel.swift deleted file mode 100644 index 4182318976..0000000000 --- a/Core/Core/SwiftUIViews/ModuleBottomNavBar/ModuleBottomNavBarViewModel.swift +++ /dev/null @@ -1,54 +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 . -// - -final class ModuleBottomNavBarViewModel: ObservableObject { - // MARK: - Dependencies - - private let router: Router - private weak var hostingViewController: UIViewController? - - // These actions are triggered from UIKit ModuleItemSequenceViewController class. - let didTapPreviousButton: () -> Void - let didTapNextButton: () -> Void - - // MARK: - Outputs - - @Published var isPreviousButtonEnabled = true - @Published var isNextButtonEnabled = true - - // MARK: - Init - - init( - didTapPreviousButton: @escaping () -> Void, - didTapNextButton: @escaping () -> Void, - router: Router, - hostingViewController: UIViewController? - ) { - self.didTapPreviousButton = didTapPreviousButton - self.didTapNextButton = didTapNextButton - self.router = router - self.hostingViewController = hostingViewController - } - - // MARK: - Inputs - - func didSelectButton(type _: ModuleNavBarButtons) { - guard let hostingViewController else { return } - router.route(to: "/tutor", from: hostingViewController, options: .modal()) - } -} diff --git a/Core/Core/SwiftUIViews/ModuleBottomNavBar/ModuleNavBarButtonType.swift b/Core/Core/SwiftUIViews/ModuleBottomNavBar/ModuleNavBarButtonType.swift deleted file mode 100644 index ae895bca26..0000000000 --- a/Core/Core/SwiftUIViews/ModuleBottomNavBar/ModuleNavBarButtonType.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// This file is part of Canvas. -// Copyright (C) 2024-present Instructure, Inc. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -// - -import Foundation -import SwiftUI - -enum ModuleNavBarButtons: CaseIterable { - case previous - case tts - case assist - case notebook - case next - - static var contentButtons: [ModuleNavBarButtons] { - [ - .tts, - .assist, - .notebook - ] - } - - var image: Image { - switch self { - case .previous: - Image(systemName: "arrow.left") - case .tts: - Image(systemName: "speaker.wave.2") - case .assist: - Image("chatBot") - case .notebook: - Image(systemName: "bookmark") - case .next: - Image(systemName: "arrow.right") - } - } -} diff --git a/Core/Core/SwiftUIViews/ModuleBottomNavBar/ModuleNavBarView.swift b/Core/Core/SwiftUIViews/ModuleBottomNavBar/ModuleNavBarView.swift deleted file mode 100644 index 288fe75fbd..0000000000 --- a/Core/Core/SwiftUIViews/ModuleBottomNavBar/ModuleNavBarView.swift +++ /dev/null @@ -1,86 +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 SwiftUI - -struct ModuleNavBarView: View { - // MARK: - Dependencies - - @ObservedObject var viewModel: ModuleBottomNavBarViewModel - - // MARK: - Properties - - private let contentButtons = ModuleNavBarButtons.contentButtons - - var body: some View { - HStack(spacing: 0) { - Button { - viewModel.didTapPreviousButton() - } label: { - iconView(type: .previous).disableWithOpacity(!viewModel.isPreviousButtonEnabled) - } - Spacer() - HStack(spacing: 8) { - ForEach(contentButtons, id: \.self) { button in - buttonView(type: button) - } - } - Spacer() - Button { - viewModel.didTapNextButton() - } label: { - iconView(type: .next).disableWithOpacity(!viewModel.isNextButtonEnabled) - } - } - .padding(.vertical, 8) - .padding(.horizontal, 16) - // TODO: Replace with predefined Horizon colors - .background(Color(hexString: "#FBF5ED")) - .clipShape(.capsule) - } - - private func buttonView(type: ModuleNavBarButtons) -> some View { - Button { - viewModel.didSelectButton(type: type) - } label: { - iconView(type: type) - } - } - - private func iconView(type: ModuleNavBarButtons) -> some View { - Circle() - .fill(Color.disabledGray.opacity(0.2)) - .frame(width: 50, height: 50) - .overlay( - type.image.foregroundStyle(Color.textDarkest) - ) - } -} - -#if DEBUG -#Preview { - ModuleNavBarView( - viewModel: .init( - didTapPreviousButton: {}, - didTapNextButton: {}, - router: AppEnvironment.shared.router, - hostingViewController: nil - ) - ) -} -#endif From 39deaabd1f384b950a3d08e887ad5813175f71bc Mon Sep 17 00:00:00 2001 From: Ahmed-Naguib93 Date: Wed, 22 Jan 2025 12:45:13 +0200 Subject: [PATCH 3/6] Refactor code --- .../Sources/Common/Data/HModuleItem.swift | 39 +++++++++++- .../Common/Data/HModuleItemSequence.swift | 48 ++++++++++++++ .../Common/Data/HModuleItemSequenceNode.swift | 37 +++++++++++ .../Assembly/ModuleItemSequenceAssembly.swift | 42 +++++++++---- .../Domain/ModuleItemSequenceInteractor.swift | 37 +++++++---- .../Domain/ModuleItemStateInteractor.swift | 10 +-- .../ModuleItemSequenceInteractorPreview.swift | 62 +++++++++++++++++++ .../ModuleItemStateInteractorPreview.swift | 32 ++++++++++ .../View/ModuleNavBarButtons.swift | 24 +++---- .../ModuleNavBar/View/ModuleNavBarView.swift | 25 +++++--- .../View/ModuleItemSequenceView.swift | 12 ++-- .../View/ModuleItemSequenceViewModel.swift | 47 +++++++------- 12 files changed, 335 insertions(+), 80 deletions(-) create mode 100644 Horizon/Horizon/Sources/Common/Data/HModuleItemSequence.swift create mode 100644 Horizon/Horizon/Sources/Common/Data/HModuleItemSequenceNode.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/Preview/ModuleItemSequenceInteractorPreview.swift create mode 100644 Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/Preview/ModuleItemStateInteractorPreview.swift diff --git a/Horizon/Horizon/Sources/Common/Data/HModuleItem.swift b/Horizon/Horizon/Sources/Common/Data/HModuleItem.swift index d6f8d257e8..51510fc900 100644 --- a/Horizon/Horizon/Sources/Common/Data/HModuleItem.swift +++ b/Horizon/Horizon/Sources/Common/Data/HModuleItem.swift @@ -28,6 +28,15 @@ struct HModuleItem: Equatable { let isLocked: Bool let moduleState: ModuleState? let points: Double? + let moduleID: String + let url: URL? + let visibleWhenLocked: Bool + let lockedForUser: Bool + let lockExplanation: String? + let courseID: String + let isQuizLTI: Bool + let completed: Bool? + let completionRequirementType: CompletionRequirementType? init( id: String, @@ -38,7 +47,17 @@ struct HModuleItem: Equatable { type: ModuleItemType? = nil, isLocked: Bool = false, moduleState: ModuleState? = nil, - points: Double? = nil + points: Double? = nil, + url: URL? = nil, + visibleWhenLocked: Bool = false, + lockedForUser: Bool = false, + lockExplanation: String? = nil, + courseID: String = "courseID", + moduleID: String = "moduleID", + isQuizLTI: Bool = false, + completed: Bool? = false, + completionRequirementType: CompletionRequirementType? = nil + ) { self.id = id self.title = title @@ -49,6 +68,15 @@ struct HModuleItem: Equatable { self.isLocked = isLocked self.moduleState = moduleState self.points = points + self.url = url + self.moduleID = moduleID + self.visibleWhenLocked = visibleWhenLocked + self.lockedForUser = lockedForUser + self.lockExplanation = lockExplanation + self.courseID = courseID + self.isQuizLTI = isQuizLTI + self.completed = completed + self.completionRequirementType = completionRequirementType } init(from entity: ModuleItem) { @@ -61,6 +89,15 @@ struct HModuleItem: Equatable { self.isLocked = entity.isLocked self.moduleState = entity.module?.state self.points = entity.pointsPossible + self.moduleID = entity.moduleID + self.url = entity.url + self.visibleWhenLocked = entity.visibleWhenLocked + self.lockedForUser = entity.lockedForUser + self.lockExplanation = entity.lockExplanation + self.courseID = entity.courseID + self.isQuizLTI = entity.isQuizLTI + self.completed = entity.completed + self.completionRequirementType = entity.completionRequirementType } var isOverDue: Bool { diff --git a/Horizon/Horizon/Sources/Common/Data/HModuleItemSequence.swift b/Horizon/Horizon/Sources/Common/Data/HModuleItemSequence.swift new file mode 100644 index 0000000000..f8983f94ad --- /dev/null +++ b/Horizon/Horizon/Sources/Common/Data/HModuleItemSequence.swift @@ -0,0 +1,48 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core + +struct HModuleItemSequence: Equatable { + let moduleID: String? + let itemID: String? + let next: HModuleItemSequenceNode? + let previous: HModuleItemSequenceNode? + let current: HModuleItemSequenceNode? + + init( + moduleID: String?, + itemID: String?, + next: HModuleItemSequenceNode?, + previous: HModuleItemSequenceNode?, + current: HModuleItemSequenceNode? + ) { + self.moduleID = moduleID + self.itemID = itemID + self.next = next + self.previous = previous + self.current = current + } + init(entity: ModuleItemSequence) { + self.moduleID = entity.current?.moduleID + self.itemID = entity.current?.id + self.next = HModuleItemSequenceNode(entity: entity.next) + self.previous = HModuleItemSequenceNode(entity: entity.prev) + self.current = HModuleItemSequenceNode(entity: entity.current) + } +} diff --git a/Horizon/Horizon/Sources/Common/Data/HModuleItemSequenceNode.swift b/Horizon/Horizon/Sources/Common/Data/HModuleItemSequenceNode.swift new file mode 100644 index 0000000000..9de405e59d --- /dev/null +++ b/Horizon/Horizon/Sources/Common/Data/HModuleItemSequenceNode.swift @@ -0,0 +1,37 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core + +struct HModuleItemSequenceNode: Equatable { + let id: String + let moduleID: String + + init(id: String, moduleID: String) { + self.id = id + self.moduleID = moduleID + } + + init?(entity: ModuleItemSequenceNode?) { + guard let entity else { + return nil + } + self.id = entity.id + self.moduleID = entity.moduleID + } +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Assembly/ModuleItemSequenceAssembly.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Assembly/ModuleItemSequenceAssembly.swift index bf2400ab80..9990b6f10f 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Assembly/ModuleItemSequenceAssembly.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Assembly/ModuleItemSequenceAssembly.swift @@ -49,10 +49,12 @@ enum ModuleItemSequenceAssembly { return CoreHostingController(view) } - static func makeModuleNavBarView(isNextButtonEnabled: Bool, - isPreviousButtonEnabled: Bool, - didTapNext: @escaping () -> Void, - didTapPrevious: @escaping () -> Void) -> ModuleNavBarView { + static func makeModuleNavBarView( + isNextButtonEnabled: Bool, + isPreviousButtonEnabled: Bool, + didTapNext: @escaping () -> Void, + didTapPrevious: @escaping () -> Void + ) -> ModuleNavBarView { let router = AppEnvironment.shared.router return ModuleNavBarView( router: router, @@ -71,22 +73,25 @@ enum ModuleItemSequenceAssembly { ModuleItemLockedView(title: title, lockExplanation: lockExplanation) } - static func makeExternalURLView(environment: AppEnvironment, - name: String, - url: URL, - courseID: String?) -> ExternalURLViewRepresentable { + static func makeExternalURLView( + environment: AppEnvironment, + name: String, + url: URL, + courseID: String? + ) -> ExternalURLViewRepresentable { ExternalURLViewRepresentable( environment: environment, name: name, url: url, courseID: courseID ) - } - static func makeLTIView(environment: AppEnvironment, - tools: LTITools, - name: String?) -> LTIViewRepresentable { + static func makeLTIView( + environment: AppEnvironment, + tools: LTITools, + name: String? + ) -> LTIViewRepresentable { LTIViewRepresentable( environment: environment, tools: tools, @@ -97,4 +102,17 @@ enum ModuleItemSequenceAssembly { static func makeModuleItemView(viewController: UIViewController) -> ModuleItemViewRepresentable { ModuleItemViewRepresentable(viewController: viewController) } + +#if DEBUG + static func makeItemSequencePreview() -> ModuleItemSequenceView { + let viewModel = ModuleItemSequenceViewModel( + moduleItemInteractor: ModuleItemSequenceInteractorPreview(), + moduleItemStateInteractor: ModuleItemStateInteractorPreview(), + assetType: .moduleItem, + assetID: "assetID" + ) + let view = ModuleItemSequenceView(viewModel: viewModel) + return view + } +#endif } diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemSequenceInteractor.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemSequenceInteractor.swift index ebd3d75b48..cc12a496d3 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemSequenceInteractor.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemSequenceInteractor.swift @@ -24,17 +24,17 @@ protocol ModuleItemSequenceInteractor { assetId: String, moduleID: String?, itemID: String? - ) -> AnyPublisher<(GetModuleItemSequence.Model?, GetModuleItem.Model?), Never> + ) -> AnyPublisher<(HModuleItemSequence?, HModuleItem?), Never> func markAsViewed(moduleID: String, itemID: String - ) -> AnyPublisher<[MarkModuleItemRead.Model], Error> + ) -> AnyPublisher<[HModuleItem], Error> func markAsDone( - item: ModuleItem?, + item: HModuleItem?, moduleID: String, itemID: String - ) -> AnyPublisher<[MarkModuleItemDone.Model], Error> + ) -> AnyPublisher<[HModuleItem], Error> func getCourseName() -> AnyPublisher func setOfflineMode(assetID: String) -> String? @@ -66,14 +66,17 @@ final class ModuleItemSequenceInteractorLive: ModuleItemSequenceInteractor { assetId: String, moduleID: String?, itemID: String? - ) -> AnyPublisher<(GetModuleItemSequence.Model?, GetModuleItem.Model?), Never> { + ) -> AnyPublisher<(HModuleItemSequence?, HModuleItem?), Never> { let sequenceUseCase = GetModuleItemSequence(courseID: courseID, assetType: assetType, assetID: assetId) let sequencePublisher = ReactiveStore(useCase: sequenceUseCase) .getEntities() .replaceError(with: []) + .flatMap { Publishers.Sequence(sequence: $0) } + .map { HModuleItemSequence(entity: $0) } + .collect() return sequencePublisher - .flatMap { [weak self] moduleItemSequence -> AnyPublisher<([GetModuleItemSequence.Model], [GetModuleItem.Model]), Never> in + .flatMap { [weak self] moduleItemSequence -> AnyPublisher<([HModuleItemSequence], [HModuleItem]), Never> in guard let self else { return Just(([], [])).eraseToAnyPublisher() } @@ -82,14 +85,16 @@ final class ModuleItemSequenceInteractorLive: ModuleItemSequenceInteractor { return Just(([], [])).eraseToAnyPublisher() } - let moduleId = moduleID ?? firstSequence.current?.moduleID - let itemId = itemID ?? firstSequence.current?.id - + let moduleId = moduleID ?? firstSequence.moduleID + let itemId = itemID ?? firstSequence.itemID if let moduleId, let itemId { let getModuleItemUseCase = GetModuleItem(courseID: courseID, moduleID: moduleId, itemID: itemId) let moduleItemPublisher = ReactiveStore(useCase: getModuleItemUseCase) .getEntities() .replaceError(with: []) + .flatMap { Publishers.Sequence(sequence: $0) } + .map { HModuleItem(from: $0) } + .collect() return moduleItemPublisher .map { moduleItems in (moduleItemSequence, moduleItems) } @@ -100,7 +105,7 @@ final class ModuleItemSequenceInteractorLive: ModuleItemSequenceInteractor { } .removeDuplicates(by: { $0.0 == $1.0 && $0.1 == $1.1 }) .receive(on: DispatchQueue.main) - .compactMap { (moduleItemSequence, moduleItems) -> (GetModuleItemSequence.Model?, GetModuleItem.Model?) in + .compactMap { (moduleItemSequence, moduleItems) -> (HModuleItemSequence?, HModuleItem?) in (moduleItemSequence.first, moduleItems.first) } .eraseToAnyPublisher() @@ -113,19 +118,22 @@ final class ModuleItemSequenceInteractorLive: ModuleItemSequenceInteractor { return firstItem?.id } - func markAsViewed(moduleID: String, itemID: String) -> AnyPublisher<[MarkModuleItemRead.Model], Error> { + func markAsViewed(moduleID: String, itemID: String) -> AnyPublisher<[HModuleItem], Error> { let useCase = MarkModuleItemRead(courseID: courseID, moduleID: moduleID, moduleItemID: itemID) return ReactiveStore(useCase: useCase) .getEntities() + .flatMap { Publishers.Sequence(sequence: $0).setFailureType(to: Error.self) } + .map { HModuleItem(from: $0) } + .collect() .receive(on: DispatchQueue.main) .eraseToAnyPublisher() } func markAsDone( - item: ModuleItem?, + item: HModuleItem?, moduleID: String, itemID: String - ) -> AnyPublisher<[MarkModuleItemDone.Model], Error> { + ) -> AnyPublisher<[HModuleItem], Error> { let useCase = MarkModuleItemDone( courseID: courseID, @@ -135,6 +143,9 @@ final class ModuleItemSequenceInteractorLive: ModuleItemSequenceInteractor { ) return ReactiveStore(useCase: useCase) .getEntities() + .flatMap { Publishers.Sequence(sequence: $0).setFailureType(to: Error.self) } + .map { HModuleItem(from: $0) } + .collect() .receive(on: DispatchQueue.main) .eraseToAnyPublisher() } diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemStateInteractor.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemStateInteractor.swift index 2d9a81541e..e06833099c 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemStateInteractor.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemStateInteractor.swift @@ -21,8 +21,8 @@ import Core protocol ModuleItemStateInteractor { func getModuleItemState( - sequence: ModuleItemSequence?, - item: ModuleItem?, + sequence: HModuleItemSequence?, + item: HModuleItem?, moduleID: String?, itemID: String? ) -> ModuleItemSequenceViewState? @@ -53,8 +53,8 @@ final class ModuleItemStateInteractorLive: ModuleItemStateInteractor { } func getModuleItemState( - sequence: ModuleItemSequence?, - item: ModuleItem?, + sequence: HModuleItemSequence?, + item: HModuleItem?, moduleID: String?, itemID: String? ) -> ModuleItemSequenceViewState? { @@ -74,7 +74,7 @@ final class ModuleItemStateInteractorLive: ModuleItemStateInteractor { } private func getModuleItemDetailsState( - item: ModuleItem?, + item: HModuleItem?, moduleID: String?, itemID: String? ) -> ModuleItemSequenceViewState? { diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/Preview/ModuleItemSequenceInteractorPreview.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/Preview/ModuleItemSequenceInteractorPreview.swift new file mode 100644 index 0000000000..9840f67f5e --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/Preview/ModuleItemSequenceInteractorPreview.swift @@ -0,0 +1,62 @@ +// +// 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 . +// + +#if DEBUG +import Core +import Combine + +final class ModuleItemSequenceInteractorPreview: ModuleItemSequenceInteractor { + func fetchModuleItems(assetId: String, moduleID: String?, itemID: String?) -> AnyPublisher<(HModuleItemSequence?, HModuleItem?), Never> { + let moduleItem = HModuleItem(id: "14", title: "Sub title 2", htmlURL: nil) + let currentModuleItem = HModuleItemSequenceNode(id: "212", moduleID: "1000") + let nextModuleItem = HModuleItemSequenceNode(id: "212", moduleID: "1000") + let perviousModuleItem = HModuleItemSequenceNode(id: "212", moduleID: "1000") + let moduleItemSequence = HModuleItemSequence( + moduleID: "11", + itemID: "222", + next: nextModuleItem, + previous: perviousModuleItem, + current: currentModuleItem + ) + + return Just((moduleItemSequence, moduleItem)) + .eraseToAnyPublisher() + } + + func markAsViewed(moduleID: String, itemID: String) -> AnyPublisher<[HModuleItem], Error> { + Just([HModuleItem(id: "14", title: "Sub title 2", htmlURL: nil)]) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + func markAsDone(item: HModuleItem?, moduleID: String, itemID: String) -> AnyPublisher<[HModuleItem], Error> { + Just([HModuleItem(id: "14", title: "Sub title 2", htmlURL: nil)]) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + func getCourseName() -> AnyPublisher { + Just("AI Course") + .eraseToAnyPublisher() + } + + func setOfflineMode(assetID: String) -> String? { + nil + } +} +#endif diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/Preview/ModuleItemStateInteractorPreview.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/Preview/ModuleItemStateInteractorPreview.swift new file mode 100644 index 0000000000..d653dcc4fa --- /dev/null +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/Preview/ModuleItemStateInteractorPreview.swift @@ -0,0 +1,32 @@ +// +// 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 . +// + +#if DEBUG +import Foundation + +final class ModuleItemStateInteractorPreview: ModuleItemStateInteractor { + func getModuleItemState( + sequence: HModuleItemSequence?, + item: HModuleItem?, + moduleID: String?, + itemID: String? + ) -> ModuleItemSequenceViewState? { + return .error + } +} +#endif diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarButtons.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarButtons.swift index 53011aca69..7b85d7fc9c 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarButtons.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarButtons.swift @@ -19,33 +19,25 @@ import SwiftUI import HorizonUI -enum ModuleNavBarButtons: CaseIterable { +enum ModuleNavBarButtons { case previous - case tts - case assist + case volume + case chatBot case notebook case next - static var contentButtons: [ModuleNavBarButtons] { - [ - .tts, - .assist, - .notebook - ] - } - var image: Image { switch self { case .previous: - Image.huiIcons.arrowBack - case .tts: + Image.huiIcons.chevronLeft + case .volume: Image.huiIcons.volumeUp - case .assist: + case .chatBot: Image(.chatBot) case .notebook: - Image.huiIcons.bookmark + Image.huiIcons.menuBookNotebook case .next: - Image.huiIcons.arrowForward + Image.huiIcons.chevronRight } } } diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarView.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarView.swift index 14f105ec39..e4ba9242b7 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarView.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarView.swift @@ -24,7 +24,6 @@ struct ModuleNavBarView: View { // MARK: - Private Properties @Environment(\.viewController) private var controller - private let contentButtons = ModuleNavBarButtons.contentButtons // MARK: - Dependencies @@ -55,13 +54,20 @@ struct ModuleNavBarView: View { } label: { iconView(type: .previous) } - .disableWithOpacity(!isPreviousButtonEnabled) + .hidden(!isPreviousButtonEnabled) Spacer() HStack(spacing: 8) { - ForEach(contentButtons, id: \.self) { button in - buttonView(type: button) + buttonView(type: .volume) + Button { + navigateToTutor() + } label: { + ModuleNavBarButtons.chatBot.image + .resizable() + .frame(width: 50, height: 50) + .huiElevation(level: .level2) } + buttonView(type: .notebook) } Spacer() Button { @@ -69,7 +75,7 @@ struct ModuleNavBarView: View { } label: { iconView(type: .next) } - .disableWithOpacity(!isNextButtonEnabled) + .hidden(!isNextButtonEnabled) } .padding(.vertical, 8) .padding(.horizontal, 16) @@ -78,18 +84,23 @@ struct ModuleNavBarView: View { private func buttonView(type: ModuleNavBarButtons) -> some View { Button { - router.route(to: "/tutor", from: controller, options: .modal()) + navigateToTutor() } label: { iconView(type: type) } } + private func navigateToTutor() { + router.route(to: "/tutor", from: controller, options: .modal()) + } + private func iconView(type: ModuleNavBarButtons) -> some View { Circle() - .fill(Color.disabledGray.opacity(0.2)) + .fill(Color.huiColors.icon.surfaceColored) .frame(width: 50, height: 50) .overlay( type.image.foregroundStyle(Color.textDarkest) ) + .huiElevation(level: .level2) } } diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift index 8012cbecd0..d4accf739f 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift @@ -30,7 +30,7 @@ public struct ModuleItemSequenceView: View { mainContent .offset(x: viewModel.offsetX) } - .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) .safeAreaInset(edge: .bottom, spacing: .zero) { moduleNavBarView } if viewModel.isLoaderVisible { HorizonUI.Spinner(size: .medium, showBackground: true) @@ -48,7 +48,7 @@ public struct ModuleItemSequenceView: View { ToolbarItem(placement: .navigationBarTrailing) { makeAsDoneButton } ToolbarItem(placement: .principal) { VStack(spacing: .huiSpaces.primitives.xxxSmall) { - Text(viewModel.item?.title ?? "") + Text(viewModel.moduleItem?.title ?? "") .foregroundStyle(Color.huiColors.text.body) .huiTypography(.labelLargeBold) Text(viewModel.courseName) @@ -61,7 +61,7 @@ public struct ModuleItemSequenceView: View { @ViewBuilder private var makeAsDoneSheetButtons: some View { - let title = viewModel.item?.completed == true + let title = viewModel.moduleItem?.completed == true ? String(localized: "Mark as Undone", bundle: .core) : String(localized: "Mark as Done", bundle: .core) Button(title) { viewModel.markAsDone()} @@ -70,7 +70,7 @@ public struct ModuleItemSequenceView: View { @ViewBuilder private var makeAsDoneButton: some View { - if viewModel.item?.completionRequirementType == .must_mark_done { + if viewModel.moduleItem?.completionRequirementType == .must_mark_done { Button(action: { isShowMakeAsDoneSheet = true }) { @@ -140,3 +140,7 @@ public struct ModuleItemSequenceView: View { .frame(height: 56) } } + +#Preview { + ModuleItemSequenceAssembly.makeItemSequencePreview() +} diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift index bf92e585eb..e6cadcdad9 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift @@ -26,12 +26,12 @@ final class ModuleItemSequenceViewModel { // MARK: - Output private(set) var viewState: ModuleItemSequenceViewState? - private(set) var isNextButtonEnabled: Bool = true - private(set) var isPreviousButtonEnabled: Bool = true + private(set) var isNextButtonEnabled: Bool = false + private(set) var isPreviousButtonEnabled: Bool = false private(set) var isLoaderVisible: Bool = false private(set) var errorMessage = "" private(set) var courseName = "" - private(set) var item: ModuleItem? + private(set) var moduleItem: HModuleItem? // MARK: - Input / Output @@ -42,7 +42,7 @@ final class ModuleItemSequenceViewModel { private var moduleID: String? private var itemID: String? private var subscriptions = Set() - private var sequence: ModuleItemSequence? + private var sequence: HModuleItemSequence? // MARK: - Dependencies @@ -94,17 +94,17 @@ final class ModuleItemSequenceViewModel { let firstSequence = result.0 self?.sequence = firstSequence self?.isNextButtonEnabled = firstSequence?.next != nil - self?.isPreviousButtonEnabled = firstSequence?.prev != nil - self?.item = result.1 + self?.isPreviousButtonEnabled = firstSequence?.previous != nil + self?.moduleItem = result.1 self?.updateModuleItemDetails() } .store(in: &subscriptions) } private func updateModuleItemDetails() { - moduleID = item?.moduleID - itemID = item?.id - var currentState = getCurrentState(item: item) + moduleID = moduleItem?.moduleID + itemID = moduleItem?.id + var currentState = getCurrentState(item: moduleItem) if currentState == nil { currentState = .error @@ -113,7 +113,7 @@ final class ModuleItemSequenceViewModel { offsetX = 0 } - private func getCurrentState(item: ModuleItem?) -> ModuleItemSequenceViewState? { + private func getCurrentState(item: HModuleItem?) -> ModuleItemSequenceViewState? { let state = moduleItemStateInteractor.getModuleItemState( sequence: sequence, item: item, @@ -127,7 +127,7 @@ final class ModuleItemSequenceViewModel { } private func markAsViewed() { - guard let moduleID, let itemID, let item else { + guard let moduleID, let itemID, let moduleItem else { return } @@ -136,9 +136,9 @@ final class ModuleItemSequenceViewModel { "itemID": itemID ]) - guard item.completionRequirementType == .must_view, - item.completed == false, - item.lockedForUser == false else { + guard moduleItem.completionRequirementType == .must_view, + moduleItem.completed == false, + moduleItem.lockedForUser == false else { return } moduleItemInteractor @@ -153,7 +153,7 @@ final class ModuleItemSequenceViewModel { } isLoaderVisible = true moduleItemInteractor.markAsDone( - item: item, + item: moduleItem, moduleID: moduleID, itemID: itemID ) @@ -168,7 +168,7 @@ final class ModuleItemSequenceViewModel { } func retry() { - fetchModuleItemSequence(assetId: item?.id ?? assetID) + fetchModuleItemSequence(assetId: moduleItem?.id ?? assetID) } func goNext() { @@ -179,15 +179,18 @@ final class ModuleItemSequenceViewModel { } func goPervious() { - guard let prev = sequence?.prev else { return } - moduleID = prev.moduleID - itemID = prev.id - update(item: prev) + guard let previous = sequence?.previous else { return } + moduleID = previous.moduleID + itemID = previous.id + update(item: previous) } - private func update(item: ModuleItemSequenceNode) { + private func update(item: HModuleItemSequenceNode) { moduleID = item.moduleID itemID = item.id - fetchModuleItemSequence(assetId: item.id) + guard let itemID else { + return + } + fetchModuleItemSequence(assetId: itemID) } } From a224e2d5ec54e99aaca6649e7ca6596d13372edf Mon Sep 17 00:00:00 2001 From: Ahmed-Naguib93 Date: Wed, 22 Jan 2025 15:03:16 +0200 Subject: [PATCH 4/6] Fix CI faild --- .../ModuleItemSequence/View/ModuleItemSequenceView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift index d4accf739f..ae8843603f 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift @@ -140,7 +140,8 @@ public struct ModuleItemSequenceView: View { .frame(height: 56) } } - +#if DEBUG #Preview { ModuleItemSequenceAssembly.makeItemSequencePreview() } +#endif From 5002217aed642762260398a1bf0f9bc92f2adc5b Mon Sep 17 00:00:00 2001 From: Ahmed-Naguib93 Date: Thu, 23 Jan 2025 14:07:41 +0200 Subject: [PATCH 5/6] Addressed code review --- .../Features/Learn/LearnAssembly.swift | 8 ++- .../Learn/View/CourseDetailsViewModel.swift | 15 ++-- .../Domain/ModuleItemSequenceInteractor.swift | 28 ++++---- .../ModuleItemSequenceAssembly.swift | 3 +- .../View/ModuleItemSequenceViewModel.swift | 9 +-- .../ModuleNavBar}/ModuleNavBarButtons.swift | 4 +- .../ModuleNavBar}/ModuleNavBarView.swift | 72 ++++++++++--------- .../ExternalURLViewRepresentable.swift | 0 .../LTIViewRepresentable.swift | 0 .../ModuleItemViewRepresentable.swift | 0 10 files changed, 72 insertions(+), 67 deletions(-) rename Horizon/Horizon/Sources/Features/ModuleItemSequence/{Assembly => }/ModuleItemSequenceAssembly.swift (98%) rename Horizon/Horizon/Sources/Features/ModuleItemSequence/{ModuleNavBar/View => View/ModuleNavBar}/ModuleNavBarButtons.swift (96%) rename Horizon/Horizon/Sources/Features/ModuleItemSequence/{ModuleNavBar/View => View/ModuleNavBar}/ModuleNavBarView.swift (67%) rename Horizon/Horizon/Sources/Features/ModuleItemSequence/{ => View}/RepresentableViews/ExternalURLViewRepresentable.swift (100%) rename Horizon/Horizon/Sources/Features/ModuleItemSequence/{ => View}/RepresentableViews/LTIViewRepresentable.swift (100%) rename Horizon/Horizon/Sources/Features/ModuleItemSequence/{ => View}/RepresentableViews/ModuleItemViewRepresentable.swift (100%) diff --git a/Horizon/Horizon/Sources/Features/Learn/LearnAssembly.swift b/Horizon/Horizon/Sources/Features/Learn/LearnAssembly.swift index d3f237451a..d9075a722f 100644 --- a/Horizon/Horizon/Sources/Features/Learn/LearnAssembly.swift +++ b/Horizon/Horizon/Sources/Features/Learn/LearnAssembly.swift @@ -33,11 +33,13 @@ final class LearnAssembly { } static func makeCourseDetailsViewController(course: HCourse) -> UIViewController { - CoreHostingController( + let appEnvironment = AppEnvironment.shared + return CoreHostingController( CourseDetailsView( viewModel: .init( - environment: AppEnvironment.shared, - course: course + router: appEnvironment.router, + course: course, + onTabBarVisibility: appEnvironment.tabBar(isVisible:) ) ) ) diff --git a/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsViewModel.swift b/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsViewModel.swift index 1f42753c49..e9388896fb 100644 --- a/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsViewModel.swift +++ b/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsViewModel.swift @@ -29,27 +29,30 @@ final class CourseDetailsViewModel: ObservableObject { // MARK: - Private - private let environment: AppEnvironment + private let router: Router + private let onTabBarVisibility: (Bool) -> Void private var subscriptions = Set() // MARK: - Init init( - environment: AppEnvironment, - course: HCourse + router: Router, + course: HCourse, + onTabBarVisibility: @escaping (Bool) -> Void ) { - self.environment = environment + self.router = router self.course = course + self.onTabBarVisibility = onTabBarVisibility self.state = .data } // MARK: - Inputs func moduleItemDidTap(url: URL, from: WeakViewController) { - environment.router.route(to: url, from: from) + router.route(to: url, from: from) } func showTabBar() { - environment.tabBar(isVisible: true) + onTabBarVisibility(true) } } diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemSequenceInteractor.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemSequenceInteractor.swift index cc12a496d3..d88c4e427b 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemSequenceInteractor.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/Domain/ModuleItemSequenceInteractor.swift @@ -18,6 +18,7 @@ import Combine import Core +import CombineSchedulers protocol ModuleItemSequenceInteractor { func fetchModuleItems( @@ -37,7 +38,6 @@ protocol ModuleItemSequenceInteractor { ) -> AnyPublisher<[HModuleItem], Error> func getCourseName() -> AnyPublisher - func setOfflineMode(assetID: String) -> String? } final class ModuleItemSequenceInteractorLive: ModuleItemSequenceInteractor { @@ -47,18 +47,18 @@ final class ModuleItemSequenceInteractorLive: ModuleItemSequenceInteractor { private let courseID: String private let assetType: AssetType - private let environment: AppEnvironment private let offlineModeInteractor: OfflineModeInteractor + private let scheduler: AnySchedulerOf init( courseID: String, assetType: AssetType, - environment: AppEnvironment, + scheduler: AnySchedulerOf = .main, offlineModeInteractor: OfflineModeInteractor = OfflineModeAssembly.make() ) { self.courseID = courseID self.assetType = assetType - self.environment = environment + self.scheduler = scheduler self.offlineModeInteractor = offlineModeInteractor } @@ -104,19 +104,19 @@ final class ModuleItemSequenceInteractorLive: ModuleItemSequenceInteractor { } } .removeDuplicates(by: { $0.0 == $1.0 && $0.1 == $1.1 }) - .receive(on: DispatchQueue.main) + .receive(on: scheduler) .compactMap { (moduleItemSequence, moduleItems) -> (HModuleItemSequence?, HModuleItem?) in (moduleItemSequence.first, moduleItems.first) } .eraseToAnyPublisher() } - func setOfflineMode(assetID: String) -> String? { - guard offlineModeInteractor.isOfflineModeEnabled(), Int(assetID) == nil else { return nil } - let moduleItems: [ModuleItem] = environment.database.viewContext.fetch(scope: .where(#keyPath(ModuleItem.pageId), equals: assetID)) - let firstItem = moduleItems.first - return firstItem?.id - } +// func setOfflineMode(assetID: String) -> String? { +// guard offlineModeInteractor.isOfflineModeEnabled(), Int(assetID) == nil else { return nil } +// let moduleItems: [ModuleItem] = environment.database.viewContext.fetch(scope: .where(#keyPath(ModuleItem.pageId), equals: assetID)) +// let firstItem = moduleItems.first +// return firstItem?.id +// } func markAsViewed(moduleID: String, itemID: String) -> AnyPublisher<[HModuleItem], Error> { let useCase = MarkModuleItemRead(courseID: courseID, moduleID: moduleID, moduleItemID: itemID) @@ -125,7 +125,7 @@ final class ModuleItemSequenceInteractorLive: ModuleItemSequenceInteractor { .flatMap { Publishers.Sequence(sequence: $0).setFailureType(to: Error.self) } .map { HModuleItem(from: $0) } .collect() - .receive(on: DispatchQueue.main) + .receive(on: scheduler) .eraseToAnyPublisher() } @@ -146,7 +146,7 @@ final class ModuleItemSequenceInteractorLive: ModuleItemSequenceInteractor { .flatMap { Publishers.Sequence(sequence: $0).setFailureType(to: Error.self) } .map { HModuleItem(from: $0) } .collect() - .receive(on: DispatchQueue.main) + .receive(on: scheduler) .eraseToAnyPublisher() } @@ -155,7 +155,7 @@ final class ModuleItemSequenceInteractorLive: ModuleItemSequenceInteractor { .getEntities() .replaceError(with: []) .map { $0.first?.name ?? "" } - .receive(on: DispatchQueue.main) + .receive(on: scheduler) .eraseToAnyPublisher() } } diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Assembly/ModuleItemSequenceAssembly.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleItemSequenceAssembly.swift similarity index 98% rename from Horizon/Horizon/Sources/Features/ModuleItemSequence/Assembly/ModuleItemSequenceAssembly.swift rename to Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleItemSequenceAssembly.swift index 9990b6f10f..b999550a86 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/Assembly/ModuleItemSequenceAssembly.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleItemSequenceAssembly.swift @@ -29,8 +29,7 @@ enum ModuleItemSequenceAssembly { ) -> UIViewController { let interactor = ModuleItemSequenceInteractorLive( courseID: courseID, - assetType: assetType, - environment: environment + assetType: assetType ) let stateInteractor = ModuleItemStateInteractorLive( environment: environment, diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift index e6cadcdad9..c006c383ec 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift @@ -68,8 +68,6 @@ final class ModuleItemSequenceViewModel { self.assetType = assetType self.assetID = assetID - offlineMode() - fetchModuleItemSequence(assetId: assetID) moduleItemInteractor.getCourseName() @@ -79,11 +77,6 @@ final class ModuleItemSequenceViewModel { .store(in: &subscriptions) } - private func offlineMode() { - if let id = moduleItemInteractor.setOfflineMode(assetID: assetID) { - fetchModuleItemSequence(assetId: id) - } - } private func fetchModuleItemSequence(assetId: String) { moduleItemInteractor.fetchModuleItems( assetId: assetId, @@ -164,7 +157,7 @@ final class ModuleItemSequenceViewModel { } self?.isLoaderVisible = false } receiveValue: { _ in} - .store(in: &subscriptions) + .store(in: &subscriptions) } func retry() { diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarButtons.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleNavBar/ModuleNavBarButtons.swift similarity index 96% rename from Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarButtons.swift rename to Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleNavBar/ModuleNavBarButtons.swift index 7b85d7fc9c..09e5ac33e7 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarButtons.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleNavBar/ModuleNavBarButtons.swift @@ -21,7 +21,7 @@ import HorizonUI enum ModuleNavBarButtons { case previous - case volume + case tts case chatBot case notebook case next @@ -30,7 +30,7 @@ enum ModuleNavBarButtons { switch self { case .previous: Image.huiIcons.chevronLeft - case .volume: + case .tts: Image.huiIcons.volumeUp case .chatBot: Image(.chatBot) diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarView.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleNavBar/ModuleNavBarView.swift similarity index 67% rename from Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarView.swift rename to Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleNavBar/ModuleNavBarView.swift index e4ba9242b7..de63cce537 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/ModuleNavBar/View/ModuleNavBarView.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleNavBar/ModuleNavBarView.swift @@ -49,58 +49,66 @@ struct ModuleNavBarView: View { var body: some View { HStack(spacing: 0) { - Button { - didTapPrevious() - } label: { - iconView(type: .previous) - } - .hidden(!isPreviousButtonEnabled) + perviousButton Spacer() HStack(spacing: 8) { - buttonView(type: .volume) - Button { - navigateToTutor() - } label: { - ModuleNavBarButtons.chatBot.image - .resizable() - .frame(width: 50, height: 50) - .huiElevation(level: .level2) - } + buttonView(type: .tts) + chatBotButton buttonView(type: .notebook) } Spacer() - Button { - didTapNext() - } label: { - iconView(type: .next) - } - .hidden(!isNextButtonEnabled) + nextButton } .padding(.vertical, 8) .padding(.horizontal, 16) .background(Color.huiColors.surface.pagePrimary) } + private var perviousButton: some View { + HorizonUI.IconButton( + ModuleNavBarButtons.previous.image, + type: .white + ) { + didTapPrevious() + } + .huiElevation(level: .level2) + .hidden(!isPreviousButtonEnabled) + } + + private var nextButton: some View { + HorizonUI.IconButton( + ModuleNavBarButtons.next.image, + type: .white + ) { + didTapNext() + } + .huiElevation(level: .level2) + .hidden(!isNextButtonEnabled) + } + private func buttonView(type: ModuleNavBarButtons) -> some View { + HorizonUI.IconButton( + type.image, + type: .white + ) { + navigateToTutor() + } + .huiElevation(level: .level2) + } + + private var chatBotButton: some View { Button { navigateToTutor() } label: { - iconView(type: type) + ModuleNavBarButtons.chatBot.image + .resizable() + .frame(width: 44, height: 44) + .huiElevation(level: .level2) } } private func navigateToTutor() { router.route(to: "/tutor", from: controller, options: .modal()) } - - private func iconView(type: ModuleNavBarButtons) -> some View { - Circle() - .fill(Color.huiColors.icon.surfaceColored) - .frame(width: 50, height: 50) - .overlay( - type.image.foregroundStyle(Color.textDarkest) - ) - .huiElevation(level: .level2) - } } diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/ExternalURLViewRepresentable.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/RepresentableViews/ExternalURLViewRepresentable.swift similarity index 100% rename from Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/ExternalURLViewRepresentable.swift rename to Horizon/Horizon/Sources/Features/ModuleItemSequence/View/RepresentableViews/ExternalURLViewRepresentable.swift diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/LTIViewRepresentable.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/RepresentableViews/LTIViewRepresentable.swift similarity index 100% rename from Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/LTIViewRepresentable.swift rename to Horizon/Horizon/Sources/Features/ModuleItemSequence/View/RepresentableViews/LTIViewRepresentable.swift diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/ModuleItemViewRepresentable.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/RepresentableViews/ModuleItemViewRepresentable.swift similarity index 100% rename from Horizon/Horizon/Sources/Features/ModuleItemSequence/RepresentableViews/ModuleItemViewRepresentable.swift rename to Horizon/Horizon/Sources/Features/ModuleItemSequence/View/RepresentableViews/ModuleItemViewRepresentable.swift From b552c65d9ba368cb93970c18f88125dd0dd2c399 Mon Sep 17 00:00:00 2001 From: Ahmed-Naguib93 Date: Thu, 23 Jan 2025 16:04:47 +0200 Subject: [PATCH 6/6] Fix code review comments --- .../Horizon/Sources/Features/Learn/LearnAssembly.swift | 2 +- .../Features/Learn/View/CourseDetailsViewModel.swift | 8 ++++---- .../ModuleItemSequence/View/ModuleItemSequenceView.swift | 6 +++--- .../View/ModuleItemSequenceViewModel.swift | 2 +- .../View/ModuleNavBar/ModuleNavBarView.swift | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Horizon/Horizon/Sources/Features/Learn/LearnAssembly.swift b/Horizon/Horizon/Sources/Features/Learn/LearnAssembly.swift index d9075a722f..93e7bd98bd 100644 --- a/Horizon/Horizon/Sources/Features/Learn/LearnAssembly.swift +++ b/Horizon/Horizon/Sources/Features/Learn/LearnAssembly.swift @@ -39,7 +39,7 @@ final class LearnAssembly { viewModel: .init( router: appEnvironment.router, course: course, - onTabBarVisibility: appEnvironment.tabBar(isVisible:) + onShowTabBar: appEnvironment.tabBar(isVisible:) ) ) ) diff --git a/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsViewModel.swift b/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsViewModel.swift index e9388896fb..142d0604fb 100644 --- a/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsViewModel.swift +++ b/Horizon/Horizon/Sources/Features/Learn/View/CourseDetailsViewModel.swift @@ -30,7 +30,7 @@ final class CourseDetailsViewModel: ObservableObject { // MARK: - Private private let router: Router - private let onTabBarVisibility: (Bool) -> Void + private let onShowTabBar: (Bool) -> Void private var subscriptions = Set() // MARK: - Init @@ -38,11 +38,11 @@ final class CourseDetailsViewModel: ObservableObject { init( router: Router, course: HCourse, - onTabBarVisibility: @escaping (Bool) -> Void + onShowTabBar: @escaping (Bool) -> Void ) { self.router = router self.course = course - self.onTabBarVisibility = onTabBarVisibility + self.onShowTabBar = onShowTabBar self.state = .data } @@ -53,6 +53,6 @@ final class CourseDetailsViewModel: ObservableObject { } func showTabBar() { - onTabBarVisibility(true) + onShowTabBar(true) } } diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift index ae8843603f..567f1e81d0 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceView.swift @@ -88,11 +88,11 @@ public struct ModuleItemSequenceView: View { } } - private func goPervious() { + private func goPrevious() { withAnimation { viewModel.offsetX = UIScreen.main.bounds.width * 2 } completion: { - viewModel.goPervious() + viewModel.goPrevious() } } @@ -135,7 +135,7 @@ public struct ModuleItemSequenceView: View { ) { goNext() } didTapPrevious: { - goPervious() + goPrevious() } .frame(height: 56) } diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift index c006c383ec..0be364b235 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleItemSequenceViewModel.swift @@ -171,7 +171,7 @@ final class ModuleItemSequenceViewModel { update(item: next) } - func goPervious() { + func goPrevious() { guard let previous = sequence?.previous else { return } moduleID = previous.moduleID itemID = previous.id diff --git a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleNavBar/ModuleNavBarView.swift b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleNavBar/ModuleNavBarView.swift index de63cce537..0ae2fe0ad6 100644 --- a/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleNavBar/ModuleNavBarView.swift +++ b/Horizon/Horizon/Sources/Features/ModuleItemSequence/View/ModuleNavBar/ModuleNavBarView.swift @@ -49,7 +49,7 @@ struct ModuleNavBarView: View { var body: some View { HStack(spacing: 0) { - perviousButton + previousButton Spacer() HStack(spacing: 8) { @@ -65,7 +65,7 @@ struct ModuleNavBarView: View { .background(Color.huiColors.surface.pagePrimary) } - private var perviousButton: some View { + private var previousButton: some View { HorizonUI.IconButton( ModuleNavBarButtons.previous.image, type: .white