From 004666e59d961187e0ca13e8d38eaf62e85815f8 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Tue, 8 Oct 2024 13:25:25 +0200 Subject: [PATCH] fix: first batch of fix PRs to synchronise with upstream (#526) * Merge pull request #23 from edx/small-fix-for-downloading-cancelling fix: fixes for downloading * chore: fix for Xcode 16 and after merge * Merge pull request #24 from edx/2U/fix/download-states fix: [iOS] On Course "Home" tab the row height * fix: after merge, deleted IAP part fix: [iOS] On Course "Home" tab the row height * Merge pull request #25 from edx/2U/feat/primary-horizontal feat: Landscape mode Improvement * fix: removed IAP part * chore: remove snack bar error for course dates info API on course home (#27) * Merge pull request #28 from shafqat-muneer/Shafqat/LEARNER-10020-ErrorHandling feat: Course Level Error Handling for Empty States * chore: remove IAP part after merging --------- Co-authored-by: Anton Yarmolenko <37253+rnr@users.noreply.github.com> Co-authored-by: Saeed Bashir --- .../noVideos.imageset/Contents.json | 15 +++ .../noVideos.imageset/noVideos.svg | 3 + .../noAnnouncements.imageset/Contents.json | 15 +++ .../noAnnouncements.svg | 1 + .../noHandouts.imageset/Contents.json | 15 +++ .../noHandouts.imageset/noHandouts.svg | 3 + .../information.imageset/Contents.json | 15 +++ .../information.imageset/Vector.svg | 3 + .../Extensions/UIApplicationExtension.swift | 16 ++- Core/Core/Network/DownloadManager.swift | 46 +++---- Core/Core/SwiftGen/Assets.swift | 4 + Core/Core/View/Base/DynamicOffsetView.swift | 35 +++-- .../FullScreenErrorView.swift | 111 +++++++++------- Core/Core/View/Base/SnackBarView.swift | 2 +- .../Container/CourseContainerView.swift | 11 +- .../Container/CourseContainerViewModel.swift | 40 +++--- Course/Course/Presentation/CourseRouter.swift | 6 +- .../Presentation/Dates/CourseDatesView.swift | 36 +++++- .../Dates/CourseDatesViewModel.swift | 10 +- .../Handouts/HandoutsUpdatesDetailView.swift | 63 ++++++--- .../Presentation/Handouts/HandoutsView.swift | 120 ++++++++---------- .../Handouts/HandoutsViewModel.swift | 11 -- .../Presentation/Offline/OfflineView.swift | 7 +- .../Outline/CourseOutlineView.swift | 116 +++++++++++------ Course/Course/SwiftGen/Strings.swift | 12 +- Course/Course/en.lproj/Localizable.strings | 6 +- .../CourseContainerViewModelTests.swift | 23 ++-- .../Unit/CourseDateViewModelTests.swift | 8 +- .../Unit/HandoutsViewModelTests.swift | 8 -- .../Elements/PrimaryCardView.swift | 58 +++++++-- .../DiscussionTopicsView.swift | 79 +++++++----- .../DiscussionTopicsViewModel.swift | 8 +- Discussion/Discussion/SwiftGen/Strings.swift | 5 + .../Discussion/en.lproj/Localizable.strings | 2 + .../DiscussionTopicsViewModelTests.swift | 6 +- .../DeepLinkRouter/DeepLinkRouter.swift | 6 +- OpenEdX/Router.swift | 6 +- 37 files changed, 594 insertions(+), 337 deletions(-) create mode 100644 Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/noVideos.svg create mode 100644 Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/noAnnouncements.svg create mode 100644 Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/noHandouts.svg create mode 100644 Core/Core/Assets.xcassets/information.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/information.imageset/Vector.svg diff --git a/Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/Contents.json b/Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/Contents.json new file mode 100644 index 000000000..48bb67ce1 --- /dev/null +++ b/Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "noVideos.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/noVideos.svg b/Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/noVideos.svg new file mode 100644 index 000000000..07b71b885 --- /dev/null +++ b/Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/noVideos.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/Contents.json b/Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/Contents.json new file mode 100644 index 000000000..d92ccd5d2 --- /dev/null +++ b/Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "noAnnouncements.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/noAnnouncements.svg b/Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/noAnnouncements.svg new file mode 100644 index 000000000..750c81c0a --- /dev/null +++ b/Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/noAnnouncements.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/Contents.json b/Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/Contents.json new file mode 100644 index 000000000..5a65a06fd --- /dev/null +++ b/Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "noHandouts.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/noHandouts.svg b/Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/noHandouts.svg new file mode 100644 index 000000000..870076e9a --- /dev/null +++ b/Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/noHandouts.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/information.imageset/Contents.json b/Core/Core/Assets.xcassets/information.imageset/Contents.json new file mode 100644 index 000000000..702db438f --- /dev/null +++ b/Core/Core/Assets.xcassets/information.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Vector.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Core/Core/Assets.xcassets/information.imageset/Vector.svg b/Core/Core/Assets.xcassets/information.imageset/Vector.svg new file mode 100644 index 000000000..93a8bd6ce --- /dev/null +++ b/Core/Core/Assets.xcassets/information.imageset/Vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index f95bff70b..090eae3e3 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -10,12 +10,21 @@ import Theme public extension UIApplication { + var windows: [UIWindow]? { + let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene + return scene?.windows + } + + var window: UIWindow? { + windows?.first + } + var keyWindow: UIWindow? { - UIApplication.shared.windows.first { $0.isKeyWindow } + windows?.first { $0.isKeyWindow } } func endEditing(force: Bool = true) { - windows.forEach { $0.endEditing(force) } + windows?.forEach { $0.endEditing(force) } } class func topViewController( @@ -36,8 +45,7 @@ public extension UIApplication { } var windowInsets: UIEdgeInsets { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first else { + guard let window = window else { return .zero } diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index ed10c0a50..6b6b30f19 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -253,9 +253,9 @@ public class DownloadManager: DownloadManagerProtocol { public func cancelDownloading(courseId: String, blocks: [CourseBlock]) async throws { downloadRequest?.cancel() - let downloaded = await getDownloadTasksForCourse(courseId).filter { $0.state == .finished } + let downloaded = await getDownloadTasksForCourse(courseId) let blocksForDelete = blocks.filter { block in - downloaded.first(where: { $0.blockId == block.id }) == nil + downloaded.first(where: { $0.blockId == block.id }) != nil } await deleteFile(blocks: blocksForDelete) downloaded.forEach { @@ -267,10 +267,10 @@ public class DownloadManager: DownloadManagerProtocol { public func cancelDownloading(task: DownloadDataTask) async throws { downloadRequest?.cancel() do { - try await persistence.deleteDownloadDataTask(id: task.id) - if let fileUrl = await fileUrl(for: task.id) { + if let fileUrl = fileUrl(for: task.id) { try FileManager.default.removeItem(at: fileUrl) } + try await persistence.deleteDownloadDataTask(id: task.id) currentDownloadEventPublisher.send(.canceled(task)) } catch { NSLog("Error deleting file: \(error.localizedDescription)") @@ -297,7 +297,8 @@ public class DownloadManager: DownloadManagerProtocol { public func deleteFile(blocks: [CourseBlock]) async { for block in blocks { do { - if let fileURL = await fileUrl(for: block.id) { + if let fileURL = fileUrl(for: block.id), + FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.removeItem(at: fileURL) } try await persistence.deleteDownloadDataTask(id: block.id) @@ -442,7 +443,7 @@ public class DownloadManager: DownloadManagerProtocol { } private func downloadFileWithProgress(_ download: DownloadDataTask) throws { - guard let url = URL(string: download.url) else { + guard let url = URL(string: download.url), let folderURL = self.filesFolderUrl else { return } @@ -452,10 +453,14 @@ public class DownloadManager: DownloadManagerProtocol { resumeData: download.resumeData ) self.isDownloadingInProgress = true + let destination: DownloadRequest.Destination = { _, _ in + let file = folderURL.appendingPathComponent(download.fileName) + return (file, [.createIntermediateDirectories, .removePreviousFile]) + } if let resumeData = download.resumeData { - downloadRequest = AF.download(resumingWith: resumeData) + downloadRequest = AF.download(resumingWith: resumeData, to: destination) } else { - downloadRequest = AF.download(url) + downloadRequest = AF.download(url, to: destination) } downloadRequest?.downloadProgress { [weak self] prog in @@ -479,18 +484,15 @@ public class DownloadManager: DownloadManagerProtocol { return } } - if let data = data.value, let url = self.filesFolderUrl { - self.saveFile(fileName: download.fileName, data: data, folderURL: url) - self.persistence.updateDownloadState( - id: download.id, - state: .finished, - resumeData: nil - ) - self.currentDownloadTask?.state = .finished - self.currentDownloadEventPublisher.send(.finished(download)) - Task { - try? await self.newDownload() - } + self.persistence.updateDownloadState( + id: download.id, + state: .finished, + resumeData: nil + ) + self.currentDownloadTask?.state = .finished + self.currentDownloadEventPublisher.send(.finished(download)) + Task { + try? await self.newDownload() } } } @@ -567,10 +569,10 @@ public class DownloadManager: DownloadManagerProtocol { private func cancel(tasks: [DownloadDataTask]) async { for task in tasks { do { - try await persistence.deleteDownloadDataTask(id: task.id) - if let fileUrl = await fileUrl(for: task.id) { + if let fileUrl = fileUrl(for: task.id) { try FileManager.default.removeItem(at: fileUrl) } + try await persistence.deleteDownloadDataTask(id: task.id) } catch { debugLog("Error deleting file: \(error.localizedDescription)") } diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index f5bede856..48ebcc9cc 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -44,6 +44,7 @@ public enum CoreAssets { public static let downloads = ImageAsset(name: "downloads") public static let home = ImageAsset(name: "home") public static let more = ImageAsset(name: "more") + public static let noVideos = ImageAsset(name: "noVideos") public static let videos = ImageAsset(name: "videos") public static let dashboardEmptyPage = ImageAsset(name: "DashboardEmptyPage") public static let addComment = ImageAsset(name: "addComment") @@ -72,6 +73,8 @@ public enum CoreAssets { public static let stopDownloading = ImageAsset(name: "stopDownloading") public static let announcements = ImageAsset(name: "announcements") public static let handouts = ImageAsset(name: "handouts") + public static let noAnnouncements = ImageAsset(name: "noAnnouncements") + public static let noHandouts = ImageAsset(name: "noHandouts") public static let dashboard = ImageAsset(name: "dashboard") public static let discovery = ImageAsset(name: "discovery") public static let learn = ImageAsset(name: "learn") @@ -111,6 +114,7 @@ public enum CoreAssets { public static let favorite = ImageAsset(name: "favorite") public static let finishedSequence = ImageAsset(name: "finished_sequence") public static let goodWork = ImageAsset(name: "goodWork") + public static let information = ImageAsset(name: "information") public static let learnEmpty = ImageAsset(name: "learn_empty") public static let airmail = ImageAsset(name: "airmail") public static let defaultMail = ImageAsset(name: "defaultMail") diff --git a/Core/Core/View/Base/DynamicOffsetView.swift b/Core/Core/View/Base/DynamicOffsetView.swift index 2c4d47fe1..da1bdf984 100644 --- a/Core/Core/View/Base/DynamicOffsetView.swift +++ b/Core/Core/View/Base/DynamicOffsetView.swift @@ -24,16 +24,19 @@ public struct DynamicOffsetView: View { @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @Binding private var viewHeight: CGFloat @State private var collapseHeight: CGFloat = .zero @Environment(\.isHorizontal) private var isHorizontal public init( coordinate: Binding, - collapsed: Binding + collapsed: Binding, + viewHeight: Binding ) { self._coordinate = coordinate self._collapsed = collapsed + self._viewHeight = viewHeight } public var body: some View { @@ -56,28 +59,36 @@ public struct DynamicOffsetView: View { } ) .onAppear { - changeCollapsedHeight() + changeCollapsedHeight(collapsed: collapsed, isHorizontal: isHorizontal) } .onChange(of: collapsed) { collapsed in if !collapsed { - changeCollapsedHeight() + changeCollapsedHeight(collapsed: collapsed, isHorizontal: isHorizontal) } } .onChange(of: isHorizontal) { isHorizontal in if isHorizontal { collapsed = true } - changeCollapsedHeight() + changeCollapsedHeight(collapsed: collapsed, isHorizontal: isHorizontal) } } - private func changeCollapsedHeight() { - collapseHeight = idiom == .pad - ? padHeight - : ( - collapsed - ? (isHorizontal ? collapsedHorizontalHeight : collapsedVerticalHeight) - : expandedHeight - ) + private func changeCollapsedHeight( + collapsed: Bool, + isHorizontal: Bool + ) { + if idiom == .pad { + collapseHeight = padHeight + } else if collapsed { + if isHorizontal { + collapseHeight = collapsedHorizontalHeight + } else { + collapseHeight = collapsedVerticalHeight + } + } else { + collapseHeight = 240 + } + viewHeight = collapseHeight } } diff --git a/Core/Core/View/Base/FullScreenErrorView/FullScreenErrorView.swift b/Core/Core/View/Base/FullScreenErrorView/FullScreenErrorView.swift index dc5893621..4e17c0626 100644 --- a/Core/Core/View/Base/FullScreenErrorView/FullScreenErrorView.swift +++ b/Core/Core/View/Base/FullScreenErrorView/FullScreenErrorView.swift @@ -10,10 +10,11 @@ import Theme public struct FullScreenErrorView: View { - public enum ErrorType { + public enum ErrorType: Equatable { case noInternet case noInternetWithReload case generic + case noContent(_ message: String, image: SwiftUI.Image) } private let errorType: ErrorType @@ -34,57 +35,69 @@ public struct FullScreenErrorView: View { } public var body: some View { - GeometryReader { proxy in - VStack(spacing: 28) { - Spacer() - switch errorType { - case .noInternet, .noInternetWithReload: - CoreAssets.noWifi.swiftUIImage - .renderingMode(.template) - .foregroundStyle(Color.primary) - .scaledToFit() - - Text(CoreLocalization.Error.Internet.noInternetTitle) - .font(Theme.Fonts.titleLarge) - .foregroundColor(Theme.Colors.textPrimary) - - Text(CoreLocalization.Error.Internet.noInternetDescription) - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) - .multilineTextAlignment(.center) - .padding(.horizontal, 50) - case .generic: - CoreAssets.notAvaliable.swiftUIImage - .renderingMode(.template) - .foregroundStyle(Color.primary) - .scaledToFit() - - Text(CoreLocalization.View.Snackbar.tryAgainBtn) - .font(Theme.Fonts.titleLarge) - .foregroundColor(Theme.Colors.textPrimary) - - Text(CoreLocalization.Error.unknownError) - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) - .multilineTextAlignment(.center) - .padding(.horizontal, 50) - } + VStack(spacing: 20) { + Spacer() + switch errorType { + case .noContent(let message, image: let image): + image + .resizable() + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textSecondary) + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 72, maxHeight: 80) - if errorType != .noInternet { - UnitButtonView( - type: .reload, - action: { - self.action() - } - ) - } - Spacer() + Text(message) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, 50) + case .noInternet, + .noInternetWithReload: + CoreAssets.noWifi.swiftUIImage + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textSecondary) + .scaledToFit() + + Text(CoreLocalization.Error.Internet.noInternetTitle) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + + Text(CoreLocalization.Error.Internet.noInternetDescription) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, 50) + case .generic: + CoreAssets.notAvaliable.swiftUIImage + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textSecondary) + .scaledToFit() + + Text(CoreLocalization.View.Snackbar.tryAgainBtn) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + + Text(CoreLocalization.Error.unknownError) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, 50) + + } + if errorType == .noInternetWithReload || errorType == .generic { + UnitButtonView( + type: .reload, + action: { + self.action() + } + ) } - .frame(maxWidth: .infinity, maxHeight: proxy.size.height) - .background( - Theme.Colors.background - ) + Spacer() } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + Theme.Colors.background + ) } } diff --git a/Core/Core/View/Base/SnackBarView.swift b/Core/Core/View/Base/SnackBarView.swift index 14ed079f4..fc61351de 100644 --- a/Core/Core/View/Base/SnackBarView.swift +++ b/Core/Core/View/Base/SnackBarView.swift @@ -14,7 +14,7 @@ public struct SnackBarView: View { var action: (() -> Void)? private var safeArea: CGFloat { - UIApplication.shared.windows.first { $0.isKeyWindow }?.safeAreaInsets.bottom ?? 0 + UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0 } private let minHeight: CGFloat = 50 diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 0b7c36ca1..65ca348d2 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -24,6 +24,7 @@ public struct CourseContainerView: View { @State private var coordinate: CGFloat = .zero @State private var lastCoordinate: CGFloat = .zero @State private var collapsed: Bool = false + @State private var viewHeight: CGFloat = .zero @Environment(\.isHorizontal) private var isHorizontal @Namespace private var animationNamespace private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @@ -93,6 +94,7 @@ public struct CourseContainerView: View { selection: $viewModel.selection, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, dateTabIndex: CourseTab.dates.rawValue ) } else { @@ -191,6 +193,7 @@ public struct CourseContainerView: View { selection: $viewModel.selection, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, dateTabIndex: CourseTab.dates.rawValue ) .tabItem { @@ -208,6 +211,7 @@ public struct CourseContainerView: View { selection: $viewModel.selection, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, dateTabIndex: CourseTab.dates.rawValue ) .tabItem { @@ -221,8 +225,8 @@ public struct CourseContainerView: View { courseID: courseID, coordinate: $coordinate, collapsed: $collapsed, - viewModel: Container.shared.resolve(CourseDatesViewModel.self, - arguments: courseID, title)! + viewHeight: $viewHeight, + viewModel: courseDatesViewModel ) .tabItem { tab.image @@ -235,6 +239,7 @@ public struct CourseContainerView: View { courseID: courseID, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, viewModel: viewModel ) .tabItem { @@ -248,6 +253,7 @@ public struct CourseContainerView: View { courseID: courseID, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, viewModel: Container.shared.resolve(DiscussionTopicsViewModel.self, argument: title)!, router: Container.shared.resolve(DiscussionRouter.self)! @@ -263,6 +269,7 @@ public struct CourseContainerView: View { courseID: courseID, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, viewModel: Container.shared.resolve(HandoutsViewModel.self, argument: courseID)! ) .tabItem { diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index a15a53d98..b8ddea494 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -188,6 +188,15 @@ public class CourseContainerViewModel: BaseCourseViewModel { ) } + @MainActor + func getCourseStructure(courseID: String) async throws -> CourseStructure? { + if isInternetAvaliable { + return try await interactor.getCourseBlocks(courseID: courseID) + } else { + return try await interactor.getLoadedCourseBlocks(courseID: courseID) + } + } + @MainActor func getCourseBlocks(courseID: String, withProgress: Bool = true) async { guard let courseStart, courseStart < Date() else { return } @@ -195,34 +204,29 @@ public class CourseContainerViewModel: BaseCourseViewModel { isShowProgress = withProgress isShowRefresh = !withProgress do { + let courseStructure = try await getCourseStructure(courseID: courseID) + await setDownloadsStates(courseStructure: courseStructure) + self.courseStructure = courseStructure + if isInternetAvaliable { - courseStructure = try await interactor.getCourseBlocks(courseID: courseID) NotificationCenter.default.post(name: .getCourseDates, object: courseID) - isShowProgress = false - isShowRefresh = false if let courseStructure { try await getResumeBlock( courseID: courseID, courseStructure: courseStructure ) } - } else { - courseStructure = try await interactor.getLoadedCourseBlocks(courseID: courseID) } courseVideosStructure = interactor.getCourseVideoBlocks(fullStructure: courseStructure!) - await setDownloadsStates() await getDownloadingProgress() isShowProgress = false isShowRefresh = false - } catch let error { + } catch { isShowProgress = false isShowRefresh = false - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } + courseStructure = nil + courseVideosStructure = nil } } @@ -234,11 +238,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { self.courseDeadlineInfo = courseDeadlineInfo } } catch let error { - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } + debugLog(error.localizedDescription) } } @@ -771,7 +771,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { func stopAllDownloads() async { do { try await manager.cancelAllDownloading() - await setDownloadsStates() + await setDownloadsStates(courseStructure: self.courseStructure) await getDownloadingProgress() } catch { errorMessage = CoreLocalization.Error.unknownError @@ -855,7 +855,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } @MainActor - func setDownloadsStates() async { + func setDownloadsStates(courseStructure: CourseStructure?) async { guard let course = courseStructure else { return } courseDownloadTasks = await manager.getDownloadTasksForCourse(course.id) downloadableVerticals = [] @@ -1080,7 +1080,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { if case .progress = state { return } Task(priority: .background) { debugLog(state, "--- state ---") - await self.setDownloadsStates() + await self.setDownloadsStates(courseStructure: self.courseStructure) await self.getDownloadingProgress() } } diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index f198f2fb5..a9b33ef79 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -46,7 +46,8 @@ public protocol CourseRouter: BaseRouter { handouts: String?, announcements: [CourseUpdate]?, router: Course.CourseRouter, - cssInjector: CSSInjector + cssInjector: CSSInjector, + type: HandoutsItemType ) func showCourseComponent( @@ -105,7 +106,8 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { handouts: String?, announcements: [CourseUpdate]?, router: Course.CourseRouter, - cssInjector: CSSInjector + cssInjector: CSSInjector, + type: HandoutsItemType ) {} public func showCourseComponent( diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index 5ec069ea5..7283f516d 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -19,16 +19,19 @@ public struct CourseDatesView: View { private var viewModel: CourseDatesViewModel @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @Binding private var viewHeight: CGFloat public init( courseID: String, coordinate: Binding, collapsed: Binding, + viewHeight: Binding, viewModel: CourseDatesViewModel ) { self.courseID = courseID self._coordinate = coordinate self._collapsed = collapsed + self._viewHeight = viewHeight self._viewModel = StateObject(wrappedValue: viewModel) } @@ -46,10 +49,33 @@ public struct CourseDatesView: View { viewModel: viewModel, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, courseDates: courseDates, courseID: courseID ) .padding(.top, 10) + } else { + GeometryReader { proxy in + VStack { + ScrollView { + DynamicOffsetView( + coordinate: $coordinate, + collapsed: $collapsed, + viewHeight: $viewHeight + ) + + FullScreenErrorView( + type: .noContent( + CourseLocalization.Error.courseDateUnavailable, + image: CoreAssets.information.swiftUIImage + ) + ) + .frame(maxWidth: .infinity) + .frame(height: proxy.size.height - viewHeight) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } } } @@ -154,6 +180,7 @@ struct CourseDateListView: View { @State private var isExpanded = false @Binding var coordinate: CGFloat @Binding var collapsed: Bool + @Binding var viewHeight: CGFloat var courseDates: CourseDates let courseID: String @@ -163,7 +190,8 @@ struct CourseDateListView: View { ScrollView { DynamicOffsetView( coordinate: $coordinate, - collapsed: $collapsed + collapsed: $collapsed, + viewHeight: $viewHeight ) VStack(alignment: .leading, spacing: 0) { @@ -479,8 +507,10 @@ struct CourseDatesView_Previews: PreviewProvider { CourseDatesView( courseID: "", coordinate: .constant(0), - collapsed: .constant(false), - viewModel: viewModel) + collapsed: .constant(false), + viewHeight: .constant(0), + viewModel: viewModel + ) } } #endif diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index c4bae6933..be2f67b26 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -91,17 +91,13 @@ public class CourseDatesViewModel: ObservableObject { await getCourseStructure(courseID: courseID) if courseDates?.courseDateBlocks == nil { isShowProgress = false - errorMessage = CoreLocalization.Error.unknownError + courseDates = nil return } isShowProgress = false - } catch let error { + } catch { isShowProgress = false - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } + courseDates = nil } } diff --git a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift index 115262c09..c4c5923c7 100644 --- a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift @@ -20,25 +20,26 @@ public struct HandoutsUpdatesDetailView: View { private var handouts: String? private var announcements: [CourseUpdate]? private let title: String + private let type: HandoutsItemType public init( handouts: String?, announcements: [CourseUpdate]?, router: CourseRouter, - cssInjector: CSSInjector + cssInjector: CSSInjector, + type: HandoutsItemType ) { - let noHandouts = handouts == nil && announcements == nil - - if announcements == nil { + switch type { + case .handouts: self.title = CourseLocalization.HandoutsCellHandouts.title - } else { + case .announcements: self.title = CourseLocalization.HandoutsCellAnnouncements.title } - - self.handouts = noHandouts ? CourseLocalization.Error.noHandouts : handouts + self.handouts = handouts self.announcements = announcements self.router = router self.cssInjector = cssInjector + self.type = type } private func updateColorScheme() { @@ -78,15 +79,31 @@ public struct HandoutsUpdatesDetailView: View { ZStack(alignment: .top) { Theme.Colors.background .ignoresSafeArea() - // MARK: - Page Body - WebViewHtml(html(), injections: [.accessibility, .readability]) - .padding(.top, 8) - .frame( - maxHeight: .infinity, - alignment: .topLeading) - .onRightSwipeGesture { - router.back() + + switch type { + case .handouts: + if handouts?.isEmpty ?? true { + FullScreenErrorView( + type: .noContent( + CourseLocalization.Error.handoutsUnavailable, + image: CoreAssets.noHandouts.swiftUIImage + ) + ) + } else { + webViewHtml } + case .announcements: + if announcements?.isEmpty ?? true { + FullScreenErrorView( + type: .noContent( + CourseLocalization.Error.announcementsUnavailable, + image: CoreAssets.noAnnouncements.swiftUIImage + ) + ) + } else { + webViewHtml + } + } } .navigationBarHidden(false) .navigationBarBackButtonHidden(false) @@ -97,6 +114,19 @@ public struct HandoutsUpdatesDetailView: View { } } + private var webViewHtml: some View { + // MARK: - Page Body + WebViewHtml(html(), injections: [.accessibility, .readability]) + .padding(.top, 8) + .frame( + maxHeight: .infinity, + alignment: .topLeading + ) + .onRightSwipeGesture { + router.back() + } + } + func html() -> String { var html: String = "" if let handouts { @@ -229,7 +259,8 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i content: loremIpsumHtml, status: "nice")], router: CourseRouterMock(), - cssInjector: CSSInjectorMock() + cssInjector: CSSInjectorMock(), + type: .handouts ) } } diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index f08075fb5..e3998963c 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -14,6 +14,7 @@ struct HandoutsView: View { private let courseID: String @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @Binding private var viewHeight: CGFloat @StateObject private var viewModel: HandoutsViewModel @@ -22,11 +23,13 @@ struct HandoutsView: View { courseID: String, coordinate: Binding, collapsed: Binding, + viewHeight: Binding, viewModel: HandoutsViewModel ) { self.courseID = courseID self._coordinate = coordinate self._collapsed = collapsed + self._viewHeight = viewHeight self._viewModel = StateObject(wrappedValue: { viewModel }()) } @@ -38,7 +41,8 @@ struct HandoutsView: View { ScrollView { DynamicOffsetView( coordinate: $coordinate, - collapsed: $collapsed + collapsed: $collapsed, + viewHeight: $viewHeight ) if viewModel.isShowProgress { HStack(alignment: .center) { @@ -48,12 +52,14 @@ struct HandoutsView: View { } } else { VStack(alignment: .leading) { - HandoutsItemCell(type: .handouts, onTapAction: { + HandoutsItemCell(type: .handouts, onTapAction: { type in viewModel.router.showHandoutsUpdatesView( handouts: viewModel.handouts, announcements: nil, router: viewModel.router, - cssInjector: viewModel.cssInjector) + cssInjector: viewModel.cssInjector, + type: type + ) viewModel.analytics.trackCourseScreenEvent( .courseHandouts, biValue: .courseHandouts, @@ -64,19 +70,19 @@ struct HandoutsView: View { .frame(height: 1) .overlay(Theme.Colors.cardViewStroke) .accessibilityIdentifier("divider") - HandoutsItemCell(type: .announcements, onTapAction: { - if !viewModel.updates.isEmpty { - viewModel.router.showHandoutsUpdatesView( - handouts: nil, - announcements: viewModel.updates, - router: viewModel.router, - cssInjector: viewModel.cssInjector) - viewModel.analytics.trackCourseScreenEvent( - .courseAnnouncement, - biValue: .courseAnnouncement, - courseID: courseID - ) - } + HandoutsItemCell(type: .announcements, onTapAction: { type in + viewModel.router.showHandoutsUpdatesView( + handouts: nil, + announcements: viewModel.updates, + router: viewModel.router, + cssInjector: viewModel.cssInjector, + type: type + ) + viewModel.analytics.trackCourseEvent( + .courseAnnouncement, + biValue: .courseAnnouncement, + courseID: courseID + ) }) }.padding(.horizontal, 32) Spacer(minLength: 84) @@ -94,22 +100,6 @@ struct HandoutsView: View { } } ) - - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) - } - .padding(.bottom, viewModel.connectivity.isInternetAvaliable - ? 0 : OfflineSnackBarView.height) - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil - } - } - } } .onFirstAppear { @@ -140,57 +130,57 @@ struct HandoutsView_Previews: PreviewProvider { courseID: "", coordinate: .constant(0), collapsed: .constant(false), + viewHeight: .constant(0), viewModel: viewModel ) } } #endif -struct HandoutsItemCell: View { +public enum HandoutsItemType: String { + case handouts + case announcements - enum ItemType { - case handouts - case announcements - - var title: String { - switch self { - case .handouts: - return CourseLocalization.HandoutsCellHandouts.title - case .announcements: - return CourseLocalization.HandoutsCellAnnouncements.title - } + var title: String { + switch self { + case .handouts: + return CourseLocalization.HandoutsCellHandouts.title + case .announcements: + return CourseLocalization.HandoutsCellAnnouncements.title } - - var description: String { - switch self { - case .handouts: - return CourseLocalization.HandoutsCellHandouts.description - case .announcements: - return CourseLocalization.HandoutsCellAnnouncements.description - } - } - - var image: Image { - switch self { - case .handouts: - return CoreAssets.handouts.swiftUIImage - case .announcements: - return CoreAssets.announcements.swiftUIImage - } + } + + var description: String { + switch self { + case .handouts: + return CourseLocalization.HandoutsCellHandouts.description + case .announcements: + return CourseLocalization.HandoutsCellAnnouncements.description } } - private let type: ItemType - private let onTapAction: () -> Void + var image: Image { + switch self { + case .handouts: + return CoreAssets.handouts.swiftUIImage + case .announcements: + return CoreAssets.announcements.swiftUIImage + } + } +} + +struct HandoutsItemCell: View { + private let type: HandoutsItemType + private let onTapAction: (HandoutsItemType) -> Void - public init(type: ItemType, onTapAction: @escaping () -> Void) { + public init(type: HandoutsItemType, onTapAction: @escaping (HandoutsItemType) -> Void) { self.type = type self.onTapAction = onTapAction } public var body: some View { Button(action: { - onTapAction() + onTapAction(type) }, label: { HStack(spacing: 12) { type.image.renderingMode(.template) diff --git a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift index c5fc64a64..f4380dc6c 100644 --- a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift +++ b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift @@ -55,11 +55,6 @@ public class HandoutsViewModel: ObservableObject { } } catch let error { isShowProgress = false - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } } } @@ -71,12 +66,6 @@ public class HandoutsViewModel: ObservableObject { isShowProgress = false } catch let error { isShowProgress = false - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } } } - } diff --git a/Course/Course/Presentation/Offline/OfflineView.swift b/Course/Course/Presentation/Offline/OfflineView.swift index 02cf0ee3c..6129b251b 100644 --- a/Course/Course/Presentation/Offline/OfflineView.swift +++ b/Course/Course/Presentation/Offline/OfflineView.swift @@ -55,6 +55,7 @@ struct OfflineView: View { private let courseID: String @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @Binding private var viewHeight: CGFloat @StateObject private var viewModel: CourseContainerViewModel @@ -63,11 +64,13 @@ struct OfflineView: View { courseID: String, coordinate: Binding, collapsed: Binding, + viewHeight: Binding, viewModel: CourseContainerViewModel ) { self.courseID = courseID self._coordinate = coordinate self._collapsed = collapsed + self._viewHeight = viewHeight self._viewModel = StateObject(wrappedValue: { viewModel }()) } @@ -88,7 +91,8 @@ struct OfflineView: View { VStack(alignment: .leading) { DynamicOffsetView( coordinate: $coordinate, - collapsed: $collapsed + collapsed: $collapsed, + viewHeight: $viewHeight ) TotalDownloadedProgressView( downloadedFilesSize: viewModel.downloadedFilesSize, @@ -260,6 +264,7 @@ struct OfflineView: View { courseID: "123", coordinate: .constant(0), collapsed: .constant(false), + viewHeight: .constant(0), viewModel: vm ).onAppear { vm.isShowProgress = false diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 4464f9fe2..a5dc918ae 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -29,6 +29,7 @@ public struct CourseOutlineView: View { @Binding private var selection: Int @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @Binding private var viewHeight: CGFloat @State private var expandedChapters: [String: Bool] = [:] @@ -40,6 +41,7 @@ public struct CourseOutlineView: View { selection: Binding, coordinate: Binding, collapsed: Binding, + viewHeight: Binding, dateTabIndex: Int ) { self.title = title @@ -49,6 +51,7 @@ public struct CourseOutlineView: View { self._selection = selection self._coordinate = coordinate self._collapsed = collapsed + self._viewHeight = viewHeight self.dateTabIndex = dateTabIndex } @@ -59,55 +62,75 @@ public struct CourseOutlineView: View { ScrollView { DynamicOffsetView( coordinate: $coordinate, - collapsed: $collapsed + collapsed: $collapsed, + viewHeight: $viewHeight ) RefreshProgressView(isShowRefresh: $viewModel.isShowRefresh) VStack(alignment: .leading) { - downloadQualityBars + if isVideo, + viewModel.isShowProgress == false { + downloadQualityBars(proxy: proxy) + } certificateView - if let continueWith = viewModel.continueWith, - let courseStructure = viewModel.courseStructure, + if viewModel.courseStructure == nil, + viewModel.isShowProgress == false, !isVideo { - let chapter = courseStructure.childs[continueWith.chapterIndex] - let sequential = chapter.childs[continueWith.sequentialIndex] - let continueUnit = sequential.childs[continueWith.verticalIndex] - - ContinueWithView( - data: continueWith, - courseContinueUnit: continueUnit - ) { - viewModel.openLastVisitedBlock() - } - } - - if let course = isVideo - ? viewModel.courseVideosStructure - : viewModel.courseStructure { - - if !isVideo, let progress = course.courseProgress, progress.totalAssignmentsCount != 0 { - CourseProgressView(progress: progress) - .padding(.horizontal, 24) - .padding(.top, 16) - .padding(.bottom, 8) - } - - // MARK: - Sections - CustomDisclosureGroup( - isVideo: isVideo, - course: course, - proxy: proxy, - viewModel: viewModel + FullScreenErrorView( + type: .noContent( + CourseLocalization.Error.coursewareUnavailable, + image: CoreAssets.information.swiftUIImage + ) ) + .frame(maxWidth: .infinity) + .frame(height: proxy.size.height - viewHeight) } else { - if let courseStart = viewModel.courseStart { - Text(courseStart > Date() ? CourseLocalization.Outline.courseHasntStarted : "") - .frame(maxWidth: .infinity) - .padding(.top, 100) + if let continueWith = viewModel.continueWith, + let courseStructure = viewModel.courseStructure, + !isVideo { + let chapter = courseStructure.childs[continueWith.chapterIndex] + let sequential = chapter.childs[continueWith.sequentialIndex] + let continueUnit = sequential.childs[continueWith.verticalIndex] + + ContinueWithView( + data: continueWith, + courseContinueUnit: continueUnit + ) { + viewModel.openLastVisitedBlock() + } + } + + if let course = isVideo + ? viewModel.courseVideosStructure + : viewModel.courseStructure { + + if !isVideo, + let progress = course.courseProgress, + progress.totalAssignmentsCount != 0 { + CourseProgressView(progress: progress) + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 8) + } + + // MARK: - Sections + CustomDisclosureGroup( + isVideo: isVideo, + course: course, + proxy: proxy, + viewModel: viewModel + ) + } else { + if let courseStart = viewModel.courseStart { + Text(courseStart > Date() ? CourseLocalization.Outline.courseHasntStarted : "") + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .padding(.top, 100) + } } + Spacer(minLength: 200) } - Spacer(minLength: 200) } .frameLimit(width: proxy.size.width) } @@ -208,9 +231,8 @@ public struct CourseOutlineView: View { } @ViewBuilder - private var downloadQualityBars: some View { - if isVideo, - let courseVideosStructure = viewModel.courseVideosStructure, + private func downloadQualityBars(proxy: GeometryProxy) -> some View { + if let courseVideosStructure = viewModel.courseVideosStructure, viewModel.hasVideoForDowbloads() { VStack(spacing: 0) { CourseVideoDownloadBarView( @@ -236,6 +258,16 @@ public struct CourseOutlineView: View { } } } + } else { + FullScreenErrorView( + type: .noContent( + CourseLocalization.Error.videosUnavailable, + image: CoreAssets.noVideos.swiftUIImage + ) + ) + .frame(maxWidth: .infinity) + .frame(height: proxy.size.height - viewHeight) + Spacer(minLength: -200) } } @ViewBuilder @@ -327,6 +359,7 @@ struct CourseOutlineView_Previews: PreviewProvider { selection: $selection, coordinate: .constant(0), collapsed: .constant(false), + viewHeight: .constant(0), dateTabIndex: 2 ) .preferredColorScheme(.light) @@ -340,6 +373,7 @@ struct CourseOutlineView_Previews: PreviewProvider { selection: $selection, coordinate: .constant(0), collapsed: .constant(false), + viewHeight: .constant(0), dateTabIndex: 2 ) .preferredColorScheme(.dark) diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 6b24786a9..84693e4a3 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -301,14 +301,22 @@ public enum CourseLocalization { public static let videos = CourseLocalization.tr("Localizable", "DOWNLOAD.VIDEOS", fallback: "Videos") } public enum Error { + /// There are currently no announcements for this course. + public static let announcementsUnavailable = CourseLocalization.tr("Localizable", "ERROR.ANNOUNCEMENTS_UNAVAILABLE", fallback: "There are currently no announcements for this course.") /// Course component not found, please reload public static let componentNotFount = CourseLocalization.tr("Localizable", "ERROR.COMPONENT_NOT_FOUNT", fallback: "Course component not found, please reload") - /// There are currently no handouts for this course - public static let noHandouts = CourseLocalization.tr("Localizable", "ERROR.NO_HANDOUTS", fallback: "There are currently no handouts for this course") + /// Course dates are not currently available. + public static let courseDateUnavailable = CourseLocalization.tr("Localizable", "ERROR.COURSE_DATE_UNAVAILABLE", fallback: "Course dates are not currently available.") + /// No course content is currently available. + public static let coursewareUnavailable = CourseLocalization.tr("Localizable", "ERROR.COURSEWARE_UNAVAILABLE", fallback: "No course content is currently available.") + /// There are currently no handouts for this course. + public static let handoutsUnavailable = CourseLocalization.tr("Localizable", "ERROR.HANDOUTS_UNAVAILABLE", fallback: "There are currently no handouts for this course.") /// You are not connected to the Internet. Please check your Internet connection. public static let noInternet = CourseLocalization.tr("Localizable", "ERROR.NO_INTERNET", fallback: "You are not connected to the Internet. Please check your Internet connection.") /// Reload public static let reload = CourseLocalization.tr("Localizable", "ERROR.RELOAD", fallback: "Reload") + /// There are currently no vidoes for this course. + public static let videosUnavailable = CourseLocalization.tr("Localizable", "ERROR.VIDEOS_UNAVAILABLE", fallback: "There are currently no vidoes for this course.") } public enum HandoutsCellAnnouncements { /// Keep up with the latest news diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index 2cd63e571..3fe024cb8 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -27,7 +27,11 @@ "ERROR.NO_INTERNET" = "You are not connected to the Internet. Please check your Internet connection."; "ERROR.RELOAD" = "Reload"; "ERROR.COMPONENT_NOT_FOUNT" = "Course component not found, please reload"; -"ERROR.NO_HANDOUTS" = "There are currently no handouts for this course"; +"ERROR.HANDOUTS_UNAVAILABLE" = "There are currently no handouts for this course."; +"ERROR.ANNOUNCEMENTS_UNAVAILABLE" = "There are currently no announcements for this course."; +"ERROR.VIDEOS_UNAVAILABLE" = "There are currently no vidoes for this course."; +"ERROR.COURSE_DATE_UNAVAILABLE" = "Course dates are not currently available."; +"ERROR.COURSEWARE_UNAVAILABLE" = "No course content is currently available."; "ALERT.ROTATE_DEVICE" = "Rotate your device to view this video in full screen."; "ALERT.ACCEPT" = "Accept"; diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 46511fb7f..09895748c 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -229,9 +229,8 @@ final class CourseContainerViewModelTests: XCTestCase { Verify(interactor, .getCourseBlocks(courseID: .any)) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) XCTAssertNil(viewModel.courseStructure) + XCTAssertNil(viewModel.courseVideosStructure) } func testGetCourseBlocksNoCacheError() async throws { @@ -270,9 +269,8 @@ final class CourseContainerViewModelTests: XCTestCase { Verify(interactor, .getCourseBlocks(courseID: .any)) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) XCTAssertNil(viewModel.courseStructure) + XCTAssertNil(viewModel.courseVideosStructure) } func testGetCourseBlocksUnknownError() async throws { @@ -311,9 +309,8 @@ final class CourseContainerViewModelTests: XCTestCase { Verify(interactor, .getCourseBlocks(courseID: .any)) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) XCTAssertNil(viewModel.courseStructure) + XCTAssertNil(viewModel.courseVideosStructure) } func testTabSelectedAnalytics() { @@ -485,7 +482,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) await viewModel.download( state: .available, @@ -615,7 +612,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) await viewModel.download( state: .available, @@ -744,7 +741,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) await viewModel.download( state: .available, @@ -874,7 +871,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -1013,7 +1010,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -1152,7 +1149,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -1313,7 +1310,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 841c56bbc..34cde8e6e 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -100,8 +100,8 @@ final class CourseDateViewModelTests: XCTestCase { Verify(interactor, .getCourseDates(courseID: .any)) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError, "Error view should be shown on unknown error.") + XCTAssertNil(viewModel.courseDates) + XCTAssertFalse(viewModel.isShowProgress) } func testNoInternetConnectionError() async throws { @@ -131,8 +131,8 @@ final class CourseDateViewModelTests: XCTestCase { Verify(interactor, .getCourseDates(courseID: .any)) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection, "Error message should be set to 'slow or no internet connection'.") + XCTAssertNil(viewModel.courseDates) + XCTAssertFalse(viewModel.isShowProgress) } func testSortedDateTodayToCourseDateBlockDict() { diff --git a/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift b/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift index c5874f90c..bad7ec2f6 100644 --- a/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift @@ -66,8 +66,6 @@ final class HandoutsViewModelTests: XCTestCase { XCTAssert(viewModel.handouts == nil) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) } func testGetHandoutsUnknownError() async throws { @@ -92,8 +90,6 @@ final class HandoutsViewModelTests: XCTestCase { XCTAssert(viewModel.handouts == nil) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) } func testGetUpdatesSuccess() async throws { @@ -146,8 +142,6 @@ final class HandoutsViewModelTests: XCTestCase { XCTAssertTrue(viewModel.updates.isEmpty) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) } func testGetUpdatesUnknownError() async throws { @@ -172,8 +166,6 @@ final class HandoutsViewModelTests: XCTestCase { XCTAssertTrue(viewModel.updates.isEmpty) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) } } diff --git a/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift index 83680dc7c..8e96c9d77 100644 --- a/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift +++ b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift @@ -27,6 +27,7 @@ public struct PrimaryCardView: View { private var assignmentAction: (String?) -> Void private var openCourseAction: () -> Void private var resumeAction: () -> Void + @Environment(\.isHorizontal) var isHorizontal public init( courseName: String, @@ -64,22 +65,65 @@ public struct PrimaryCardView: View { public var body: some View { ZStack { + if isHorizontal { + horizontalLayout + } else { + verticalLayout + } + } + .background(Theme.Colors.courseCardBackground) + .cornerRadius(8) + .shadow(color: Theme.Colors.courseCardShadow, radius: 4, x: 0, y: 3) + .padding(20) + } + + @ViewBuilder + var verticalLayout: some View { + VStack(alignment: .leading, spacing: 0) { + Group { + courseBanner + .frame(height: 140) + .clipped() + ProgressLineView(progressEarned: progressEarned, progressPossible: progressPossible) + courseTitle + } + .onTapGesture { + openCourseAction() + } + assignments + } + } + + @ViewBuilder + var horizontalLayout: some View { + HStack(alignment: .top, spacing: 0) { VStack(alignment: .leading, spacing: 0) { - Group { + GeometryReader { proxy in courseBanner - ProgressLineView(progressEarned: progressEarned, progressPossible: progressPossible) + .frame(width: proxy.size.width) + .clipped() + } + ProgressLineView(progressEarned: progressEarned, progressPossible: progressPossible) + } + .onTapGesture { + openCourseAction() + } + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .leading) { courseTitle } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .background( + Theme.Colors.background // need for tap area + ) + .onTapGesture { openCourseAction() } assignments } } - .background(Theme.Colors.courseCardBackground) - .cornerRadius(8) - .shadow(color: Theme.Colors.courseCardShadow, radius: 4, x: 0, y: 3) - .padding(20) + .frame(minHeight: 240) } private var assignments: some View { @@ -223,8 +267,6 @@ public struct PrimaryCardView: View { .onFailureImage(CoreAssets.noCourseImage.image) .resizable() .aspectRatio(contentMode: .fill) - .frame(height: 140) - .clipped() .accessibilityElement(children: .ignore) .accessibilityIdentifier("course_image") } diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index 77e23d8e1..ee44d90d1 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -17,12 +17,14 @@ public struct DiscussionTopicsView: View { private let courseID: String @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @Binding private var viewHeight: CGFloat @State private var runOnce: Bool = false public init( courseID: String, coordinate: Binding, collapsed: Binding, + viewHeight: Binding, viewModel: DiscussionTopicsViewModel, router: DiscussionRouter ) { @@ -30,6 +32,7 @@ public struct DiscussionTopicsView: View { self.courseID = courseID self._coordinate = coordinate self._collapsed = collapsed + self._viewHeight = viewHeight self.router = router } @@ -40,7 +43,8 @@ public struct DiscussionTopicsView: View { ScrollView { DynamicOffsetView( coordinate: $coordinate, - collapsed: $collapsed + collapsed: $collapsed, + viewHeight: $viewHeight ) RefreshProgressView(isShowRefresh: $viewModel.isShowRefresh) // MARK: - Search fake field @@ -48,37 +52,39 @@ public struct DiscussionTopicsView: View { bannerDiscussionsDisabled } - HStack(spacing: 11) { - Image(systemName: "magnifyingglass") - .foregroundColor(Theme.Colors.textInputTextColor) - .padding(.leading, 16) - .padding(.top, 1) - Text(DiscussionLocalization.Topics.search) - .foregroundColor(Theme.Colors.textInputTextColor) - .font(Theme.Fonts.bodyMedium) - Spacer() - } - .frame(minHeight: 48) - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(Theme.Colors.textInputUnfocusedStroke) - ) - .onTapGesture { - viewModel.router.showDiscussionsSearch( - courseID: courseID, - isBlackedOut: viewModel.isBlackedOut + if let topics = viewModel.discussionTopics, topics.count > 0 { + HStack(spacing: 11) { + Image(systemName: "magnifyingglass") + .foregroundColor(Theme.Colors.textInputTextColor) + .padding(.leading, 16) + .padding(.top, 1) + Text(DiscussionLocalization.Topics.search) + .foregroundColor(Theme.Colors.textInputTextColor) + .font(Theme.Fonts.bodyMedium) + Spacer() + } + .frame(minHeight: 48) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputUnfocusedStroke) ) + .onTapGesture { + viewModel.router.showDiscussionsSearch( + courseID: courseID, + isBlackedOut: viewModel.isBlackedOut + ) + } + .frameLimit(width: proxy.size.width) + .padding(.horizontal, 24) + .padding(.top, 10) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DiscussionLocalization.Topics.search) } - .frameLimit(width: proxy.size.width) - .padding(.horizontal, 24) - .padding(.top, 10) - .accessibilityElement(children: .ignore) - .accessibilityLabel(DiscussionLocalization.Topics.search) // MARK: - Page Body VStack { @@ -154,7 +160,16 @@ public struct DiscussionTopicsView: View { } } } - + } else if viewModel.isShowProgress == false { + FullScreenErrorView( + type: .noContent( + DiscussionLocalization.Error.unableToLoadDiscussion, + image: CoreAssets.information.swiftUIImage + ) + ) + .frame(maxWidth: .infinity) + .frame(height: proxy.size.height - viewHeight) + Spacer(minLength: -200) } Spacer(minLength: 200) } @@ -225,6 +240,7 @@ struct DiscussionView_Previews: PreviewProvider { courseID: "", coordinate: .constant(0), collapsed: .constant(false), + viewHeight: .constant(0), viewModel: vm, router: router ) @@ -236,6 +252,7 @@ struct DiscussionView_Previews: PreviewProvider { courseID: "", coordinate: .constant(0), collapsed: .constant(false), + viewHeight: .constant(0), viewModel: vm, router: router ) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift index 51fde8edd..0984ebb6b 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift @@ -182,14 +182,10 @@ public class DiscussionTopicsViewModel: ObservableObject { discussionTopics = generateTopics(topics: topics) isShowProgress = false isShowRefresh = false - } catch let error { + } catch { isShowProgress = false isShowRefresh = false - if error.isInternetError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } + discussionTopics = nil } } } diff --git a/Discussion/Discussion/SwiftGen/Strings.swift b/Discussion/Discussion/SwiftGen/Strings.swift index 487a95f22..3346df120 100644 --- a/Discussion/Discussion/SwiftGen/Strings.swift +++ b/Discussion/Discussion/SwiftGen/Strings.swift @@ -71,6 +71,11 @@ public enum DiscussionLocalization { /// Topic public static let topic = DiscussionLocalization.tr("Localizable", "CREATE_THREAD.TOPIC", fallback: "Topic") } + public enum Error { + /// Unable to load discussions. + /// Try again later. + public static let unableToLoadDiscussion = DiscussionLocalization.tr("Localizable", "ERROR.UNABLE_TO_LOAD_DISCUSSION", fallback: "Unable to load discussions.\nTry again later.") + } public enum Post { /// Last post: public static let lastPost = DiscussionLocalization.tr("Localizable", "POST.LAST_POST", fallback: "Last post:") diff --git a/Discussion/Discussion/en.lproj/Localizable.strings b/Discussion/Discussion/en.lproj/Localizable.strings index 55819f2ae..2c4165378 100644 --- a/Discussion/Discussion/en.lproj/Localizable.strings +++ b/Discussion/Discussion/en.lproj/Localizable.strings @@ -61,3 +61,5 @@ "SEARCH.EMPTY_DESCRIPTION" = "Start typing to find the topics"; "ANONYMOUS" = "Anonymous"; + +"ERROR.UNABLE_TO_LOAD_DISCUSSION" = "Unable to load discussions.\nTry again later."; diff --git a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift index 32f957a85..eea0bb06f 100644 --- a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift @@ -87,9 +87,8 @@ final class DiscussionTopicsViewModelTests: XCTestCase { XCTAssertNil(viewModel.topics) XCTAssertNil(viewModel.discussionTopics) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) XCTAssertFalse(viewModel.isShowProgress) + XCTAssertFalse(viewModel.isShowRefresh) } func testGetTopicsUnknownError() async throws { @@ -113,8 +112,7 @@ final class DiscussionTopicsViewModelTests: XCTestCase { XCTAssertNil(viewModel.topics) XCTAssertNil(viewModel.discussionTopics) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) XCTAssertFalse(viewModel.isShowProgress) + XCTAssertFalse(viewModel.isShowRefresh) } } diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift index 15f026235..259cc2171 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift @@ -171,7 +171,8 @@ extension Router: DeepLinkRouter { handouts: nil, announcements: updates, router: self, - cssInjector: cssInjector + cssInjector: cssInjector, + type: .announcements ) } @@ -187,7 +188,8 @@ extension Router: DeepLinkRouter { handouts: handouts, announcements: nil, router: self, - cssInjector: cssInjector + cssInjector: cssInjector, + type: .handouts ) } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index d06b65e9a..b90f405d9 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -435,13 +435,15 @@ public class Router: AuthorizationRouter, handouts: String?, announcements: [CourseUpdate]?, router: Course.CourseRouter, - cssInjector: CSSInjector + cssInjector: CSSInjector, + type: HandoutsItemType ) { let view = HandoutsUpdatesDetailView( handouts: handouts, announcements: announcements, router: router, - cssInjector: cssInjector + cssInjector: cssInjector, + type: type ) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true)