Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,17 @@
import Combine
import Foundation

struct DownloadTaskParameters {
public struct DownloadTaskParameters {
let remoteURL: URL
let localURL: URL

public init(remoteURL: URL, localURL: URL) {
self.remoteURL = remoteURL
self.localURL = localURL
}
}

struct DownloadTaskPublisher: Publisher {
public struct DownloadTaskPublisher: Publisher {
/// Output is a Float value between 0 and 1 indicating the download progress.
public typealias Output = Float
public typealias Failure = Error
Expand Down
2 changes: 1 addition & 1 deletion Core/Core/Features/Files/Model/API/APIFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ public extension APIUsageRights {
public struct GetFileRequest: APIRequestable {
public typealias Response = APIFile

enum Include: String, Codable {
public enum Include: String, Codable {
case avatar, usage_rights, user
}

Expand Down
12 changes: 6 additions & 6 deletions Core/Core/Features/Files/Model/API/GetFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,28 @@

import CoreData

class GetFile: APIUseCase {
typealias Model = File
public class GetFile: APIUseCase {
public typealias Model = File

let context: Context?
let fileID: String
let include: [GetFileRequest.Include]

init(context: Context?, fileID: String, include: [GetFileRequest.Include] = []) {
public init(context: Context?, fileID: String, include: [GetFileRequest.Include] = []) {
self.context = context
self.fileID = fileID
self.include = include
}

var cacheKey: String? {
public var cacheKey: String? {
return "get-file-\(fileID)"
}

var scope: Scope {
public var scope: Scope {
return .where(#keyPath(File.id), equals: fileID)
}

var request: GetFileRequest {
public var request: GetFileRequest {
return GetFileRequest(context: context, fileID: fileID, include: include)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public class FileDetailsViewController: ScreenViewTrackableViewController, CoreW
private var imageLoader: ImageLoader?
private var isFileLocalURLAvailable: Bool { localURL != nil }
private var isPresentingOfflineModeAlert = false

public var didFinishLoading: () -> Void = { }
public static func create(
context: Context?,
fileID: String,
Expand Down Expand Up @@ -303,6 +303,9 @@ public class FileDetailsViewController: ScreenViewTrackableViewController, CoreW
progressView.isHidden = true
let courseID = context?.contextType == .course ? context?.id : nil
NotificationCenter.default.post(moduleItem: .file(fileID), completedRequirement: .view, courseID: courseID ?? "")
DispatchQueue.main.async {
self.didFinishLoading()
}
}

@IBAction func viewModules() {
Expand Down
3 changes: 0 additions & 3 deletions Core/Core/Features/LTI/View/LTIViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ public class LTIViewController: UIViewController, ErrorViewController, ColoredNa
}

func updateNavBar() {
guard env.app != .horizon else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, I hadn't noticed this when I did the LTI and external URL stories.

return
}
let course = courses?.first
updateNavBar(subtitle: course?.name, color: course?.color)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,6 @@ public class ExternalURLViewController: UIViewController, ColoredNavViewProtocol
}

public func updateNavBar() {
guard env.app != .horizon else {
return
}
let course = courses?.first
updateNavBar(subtitle: course?.name, color: course?.color)
}
Expand Down
3 changes: 3 additions & 0 deletions Horizon/Horizon/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
},
"Confusing" : {

},
"Download File" : {

},
"Edit" : {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//
// 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 <https://www.gnu.org/licenses/>.
//

import Foundation
import Core
import Combine

protocol DownloadFileInteractor {
func download() -> AnyPublisher<URL, Error>
}

final class DownloadFileInteractorLive: DownloadFileInteractor {
// MARK: - Dependencies

private let courseID: String
private let fileID: String
private let fileManager: FileManager

// MARK: - Init

init(
courseID: String,
fileID: String,
fileManager: FileManager = .default
) {
self.courseID = courseID
self.fileID = fileID
self.fileManager = fileManager
}

func download() -> AnyPublisher<URL, Error> {
ReactiveStore(
useCase: GetFile(context: .course(courseID), fileID: fileID)
)
.getEntities(ignoreCache: true)
.flatMap { [weak self] files -> AnyPublisher<URL, Error> in
guard let self,
let file = files.first,
let url = file.url
else {
return Empty(completeImmediately: true)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
let localURL = URL.Directories.documents.appendingPathComponent(file.filename)

if self.fileManager.fileExists(atPath: localURL.path) {
return Just(localURL)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
} else {
return DownloadTaskPublisher(parameters:
DownloadTaskParameters(
remoteURL: url,
localURL: localURL
)
)
.collect() // Wait until the download is finished.
.mapToValue(localURL)
.eraseToAnyPublisher()
}
}
.eraseToAnyPublisher()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@ final class ModuleItemStateInteractorLive: ModuleItemStateInteractor {
} else {
return .externalURL(
url: url,
name: String(localized: "Unsupported Item", bundle: .horizon),
courseID: courseID
name: String(localized: "Unsupported Item", bundle: .horizon)
)
}
}
Expand All @@ -86,7 +85,7 @@ final class ModuleItemStateInteractorLive: ModuleItemStateInteractor {

switch item.type {
case .externalURL(let url):
return .externalURL(url: url, name: item.title, courseID: item.courseID)
return .externalURL(url: url, name: item.title)
case let .externalTool(toolID, url):
let tools = LTITools(
env: environment,
Expand All @@ -102,6 +101,9 @@ final class ModuleItemStateInteractorLive: ModuleItemStateInteractor {

case .assignment(let id):
return .assignment(courseID: courseID, assignmentID: id)
case .file(let id):
guard let url = item.url, let context = Context(path: url.path) else { return nil }
return .file(context: context, fileID: id)
default:
guard let url = item.url else { return nil }
let preparedURL = url.appendingOrigin("module_item_details")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// 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 <https://www.gnu.org/licenses/>.
//

#if DEBUG
import Foundation
import Combine

class DownloadFileInteractorPreview: DownloadFileInteractor {
func download() -> AnyPublisher<URL, Error> {
Just(URL(string: "https://github.com/instructure/canvas-ios")!)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// 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 <https://www.gnu.org/licenses/>.
//

import Foundation
import Core
import SwiftUI

struct FileDetailsAssembly {
static func makeView(
courseID: String,
fileID: String,
context: Context,
fileName: String,
isShowHeader: Binding<Bool>
) -> FileDetailsView {
let interactor = DownloadFileInteractorLive(courseID: courseID, fileID: fileID)
let router = AppEnvironment.shared.router
let viewModel = FileDetailsViewModel(interactor: interactor, router: router)
return FileDetailsView(
viewModel: viewModel,
context: context,
fileID: fileID,
fileName: fileName,
isShowHeader: isShowHeader
)
}

#if DEBUG
static func makePreview() -> FileDetailsView {
let interactor = DownloadFileInteractorPreview()
let router = AppEnvironment.shared.router
let viewModel = FileDetailsViewModel(interactor: interactor, router: router)
let view = FileDetailsView(
viewModel: viewModel,
context: nil,
fileID: "23",
fileName: "AI for Everyone.pdf",
isShowHeader: .constant(true)
)
return view
}
#endif
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// 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 <https://www.gnu.org/licenses/>.
//

import SwiftUI
import Core
import HorizonUI

struct FileDetailsView: View {
// MARK: - Private Properties

@State private var didFinishRenderingPreview: Bool = false
@Environment(\.viewController) private var viewController

// MARK: - Dependencies

@State private var viewModel: FileDetailsViewModel
private let context: Context?
private let fileID: String
private let fileName: String
@Binding var isShowHeader: Bool

init(
viewModel: FileDetailsViewModel,
context: Context?,
fileID: String,
fileName: String,
isShowHeader: Binding<Bool>
) {
self.context = context
self.fileID = fileID
self.fileName = fileName
self._isShowHeader = isShowHeader
self.viewModel = viewModel
}

var body: some View {
VStack {
if isShowHeader {
FileDownloadStatusView(status: viewModel.viewState, fileName: fileName) {
viewModel.downloadFile(viewController: viewController)
} onTapCancel: {
viewModel.cancelDownload()
}
.padding(.vertical, .huiSpaces.primitives.small)
.padding(.horizontal, .huiSpaces.primitives.medium)
.hidden(!didFinishRenderingPreview)
}
FileDetailsViewRepresentable(
isScrollTopReached: $isShowHeader,
isFinishLoading: $didFinishRenderingPreview,
context: context,
fileID: fileID
)
}
.animation(.smooth, value: viewModel.viewState)
.animation(.smooth, value: [isShowHeader, didFinishRenderingPreview])
}
}
#if DEBUG
#Preview {
FileDetailsAssembly.makePreview()
}
#endif
Loading