From 047eeddb55ef7d4279d18ed85bab92df18e8f847 Mon Sep 17 00:00:00 2001 From: Mohamed Afifi Date: Tue, 26 Dec 2023 11:43:05 -0500 Subject: [PATCH] Refactor ContentViewModel to have a single source of truth --- .../Sources/Entities/DownloadRequest.swift | 2 +- .../ContentViewController.swift | 66 ++++------------- .../ContentViewModel.swift | 73 +++++++++---------- .../ContentImageBuilder.swift | 39 +++++----- .../QuranPagesFeature/PageViewBuilder.swift | 2 +- .../ContentTranslationBuilder.swift | 25 ++++--- .../SelectedTranslationsMonitor.swift | 25 +++++++ 7 files changed, 111 insertions(+), 121 deletions(-) create mode 100644 Features/QuranTranslationFeature/SelectedTranslationsMonitor.swift diff --git a/Data/BatchDownloader/Sources/Entities/DownloadRequest.swift b/Data/BatchDownloader/Sources/Entities/DownloadRequest.swift index 5e40060b..37b7019e 100644 --- a/Data/BatchDownloader/Sources/Entities/DownloadRequest.swift +++ b/Data/BatchDownloader/Sources/Entities/DownloadRequest.swift @@ -43,7 +43,7 @@ public struct DownloadRequest: Hashable, Sendable { // MARK: Private - private static let downloadResumeDataExtension = ".resume" + private static let downloadResumeDataExtension = "resume" } public struct DownloadBatchRequest: Hashable, Sendable { diff --git a/Features/QuranContentFeature/ContentViewController.swift b/Features/QuranContentFeature/ContentViewController.swift index 6f18e87a..e12ec8c2 100644 --- a/Features/QuranContentFeature/ContentViewController.swift +++ b/Features/QuranContentFeature/ContentViewController.swift @@ -6,11 +6,11 @@ // Copyright © 2019 Quran.com. All rights reserved. // -import Combine import NoorUI import QuranAnnotations import QuranKit import QuranPagesFeature +import QuranText import QuranTextKit import SwiftUI import UIKit @@ -39,8 +39,7 @@ public final class ContentViewController: UIViewController, UIGestureRecognizerD super.viewDidLoad() view.backgroundColor = .reading setUpGesture() - setUpPageCollectionBuilderChanges() - setUpHighlightsListener() + setUpPagesView() } public func gestureRecognizer( @@ -66,59 +65,15 @@ public final class ContentViewController: UIViewController, UIGestureRecognizerD // MARK: Private - private var pagingController: UIViewController? private let viewModel: ContentViewModel - private var cancellables: Set = [] - private var lastHighlights: QuranHighlights? private var pageViews: [PageView] { findPageViews(in: self) } - private func setUpHighlightsListener() { - viewModel.deps.highlightsService.$highlights - .receive(on: DispatchQueue.main) - .sink { [weak self] newHighlights in - self?.highlightsUpdatedTo(newHighlights) - } - .store(in: &cancellables) - } - - private func highlightsUpdatedTo(_ highlights: QuranHighlights) { - defer { - lastHighlights = highlights - } - - guard let oldValue = lastHighlights else { - return - } - - if let ayah = highlights.verseToScrollTo(comparingTo: oldValue) { - viewModel.visiblePages = [ayah.page] - } - } - - private func setUpPageCollectionBuilderChanges() { - viewModel.$pageViewBuilder - .receive(on: DispatchQueue.main) - .sink { [weak self] pageViewBuilder in - self?.install(pageViewBuilder) - } - .store(in: &cancellables) - } - - private func install(_ pageViewBuilder: PageViewBuilder?) { - guard let pageViewBuilder else { - return - } - - if let oldPagingController = pagingController { - removeChild(oldPagingController) - } - - let pagesView = PagesView(viewModel: viewModel, pageBuilder: pageViewBuilder.build()) + private func setUpPagesView() { + let pagesView = PagesView(viewModel: viewModel) let pagingController = UIHostingController(rootView: pagesView) - self.pagingController = pagingController addFullScreenChild(pagingController) } @@ -186,18 +141,25 @@ public final class ContentViewController: UIViewController, UIGestureRecognizerD } private struct PagesView: View { + private struct PagesId: Hashable { + let quranMode: QuranMode + let selectedTranslations: [Translation.ID] + } + + // MARK: Internal + @ObservedObject var viewModel: ContentViewModel - let pageBuilder: (Page) -> UIViewController var body: some View { GeometryReader { geometry in QuranPaginationView( pagingStrategy: pagingStrategy(with: geometry), selection: $viewModel.visiblePages, - pages: viewModel.pages + pages: viewModel.deps.quran.pages ) { page in - StaticViewControllerRepresentable(viewController: pageBuilder(page)) + StaticViewControllerRepresentable(viewController: viewModel.pageViewBuilder.build(at: page)) } + .id(PagesId(quranMode: viewModel.quranMode, selectedTranslations: viewModel.selectedTranslations)) } } diff --git a/Features/QuranContentFeature/ContentViewModel.swift b/Features/QuranContentFeature/ContentViewModel.swift index a92b0590..0e6736c1 100644 --- a/Features/QuranContentFeature/ContentViewModel.swift +++ b/Features/QuranContentFeature/ContentViewModel.swift @@ -58,26 +58,27 @@ public final class ContentViewModel: ObservableObject { self.input = input visiblePages = [input.initialPage] - pages = deps.quran.pages + highlights = deps.highlightsService.highlights twoPagesEnabled = deps.quranContentStatePreferences.twoPagesEnabled - verticalScrollingEnabled = deps.quranContentStatePreferences.verticalScrollingEnabled + quranMode = deps.quranContentStatePreferences.quranMode + selectedTranslations = deps.selectedTranslationsPreferences.selectedTranslations + deps.highlightsService.$highlights + .sink { [weak self] in self?.highlights = $0 } + .store(in: &cancellables) deps.quranContentStatePreferences.$twoPagesEnabled .sink { [weak self] in self?.twoPagesEnabled = $0 } .store(in: &cancellables) - deps.quranContentStatePreferences.$verticalScrollingEnabled - .sink { [weak self] in self?.verticalScrollingEnabled = $0 } - .store(in: &cancellables) deps.quranContentStatePreferences.$quranMode - .sink { [weak self] _ in self?.reloadAllPages() } + .sink { [weak self] in self?.quranMode = $0 } .store(in: &cancellables) deps.selectedTranslationsPreferences.$selectedTranslations - .sink { [weak self] _ in self?.reloadAllPages() } + .sink { [weak self] in self?.selectedTranslations = $0 } .store(in: &cancellables) loadNotes() - configureAsInitialPage() + configureInitialPage() } // MARK: Public @@ -98,11 +99,11 @@ public final class ContentViewModel: ObservableObject { } public func highlightWord(_ word: Word?) { - deps.highlightsService.highlights.pointedWord = word + highlights.pointedWord = word } public func highlightReadingAyah(_ ayah: AyahNumber?) { - deps.highlightsService.highlights.readingVerses = [ayah].compactMap { $0 } + highlights.readingVerses = [ayah].compactMap { $0 } } // MARK: Internal @@ -110,18 +111,31 @@ public final class ContentViewModel: ObservableObject { let deps: Deps weak var listener: ContentListener? + @Published var quranMode: QuranMode + @Published var selectedTranslations: [Translation.ID] @Published var twoPagesEnabled: Bool - @Published var pageViewBuilder: PageViewBuilder? - let pages: [Page] + @Published var highlights: QuranHighlights { + didSet { + if oldValue != highlights { + deps.highlightsService.highlights = highlights + + if let ayah = highlights.verseToScrollTo(comparingTo: oldValue) { + visiblePages = [ayah.page] + } + } + } + } var pagingStrategy: PagingStrategy { - let shouldDisplayTwoPages = !verticalScrollingEnabled && twoPagesEnabled - return shouldDisplayTwoPages ? .doublePage : .singlePage + twoPagesEnabled ? .doublePage : .singlePage } - var verticalScrollingEnabled: Bool { - didSet { reloadAllPages() } + var pageViewBuilder: PageViewBuilder { + switch deps.quranContentStatePreferences.quranMode { + case .arabic: return deps.imageDataSourceBuilder + case .translation: return deps.translationDataSourceBuilder + } } func onViewLongPressStarted(at point: CGPoint, sourceView: UIView, verse: AyahNumber) { @@ -165,7 +179,7 @@ public final class ContentViewModel: ObservableObject { private var longPressData: LongPressData? { didSet { - deps.highlightsService.highlights.shareVerses = selectedVerses ?? [] + highlights.shareVerses = selectedVerses ?? [] } } @@ -181,13 +195,6 @@ public final class ContentViewModel: ObservableObject { return start.array(to: end) } - private var newPageCollectionBuilder: PageViewBuilder { - switch deps.quranContentStatePreferences.quranMode { - case .arabic: return deps.imageDataSourceBuilder - case .translation: return deps.translationDataSourceBuilder - } - } - private static func dictionaryFrom(_ array: [(K, U)]) -> [K: U] { var dict: [K: U] = [:] for element in array { @@ -196,15 +203,14 @@ public final class ContentViewModel: ObservableObject { return dict } - private func configureAsInitialPage() { + private func configureInitialPage() { deps.lastPageUpdater.configure(initialPage: input.initialPage, lastPage: input.lastPage) - reloadAllPages() - deps.highlightsService.highlights.searchVerses = [input.highlightingSearchAyah].compactMap { $0 } + highlights.searchVerses = [input.highlightingSearchAyah].compactMap { $0 } } private func visiblePagesUpdated() { // remove search highlight when page changes - deps.highlightsService.highlights.searchVerses = [] + highlights.searchVerses = [] let pages = visiblePages let isTranslationView = deps.quranContentStatePreferences.quranMode == .translation @@ -227,20 +233,11 @@ public final class ContentViewModel: ObservableObject { deps.lastPageUpdater.updateTo(pages: pages) } - private func reloadAllPages() { - switch deps.quranContentStatePreferences.quranMode { - case .arabic: - pageViewBuilder = deps.imageDataSourceBuilder - case .translation: - pageViewBuilder = deps.translationDataSourceBuilder - } - } - private func loadNotes() { deps.noteService.notes(quran: deps.quran) .map { notes in notes.flatMap { note in note.verses.map { ($0, note) } } } .receive(on: DispatchQueue.main) - .sink { [weak self] in self?.deps.highlightsService.highlights.noteVerses = Self.dictionaryFrom($0) } + .sink { [weak self] in self?.highlights.noteVerses = Self.dictionaryFrom($0) } .store(in: &cancellables) } } diff --git a/Features/QuranImageFeature/ContentImageBuilder.swift b/Features/QuranImageFeature/ContentImageBuilder.swift index d5a4368b..40a2965d 100644 --- a/Features/QuranImageFeature/ContentImageBuilder.swift +++ b/Features/QuranImageFeature/ContentImageBuilder.swift @@ -27,13 +27,9 @@ public struct ContentImageBuilder: PageViewBuilder { public init(container: AppDependencies, highlightsService: QuranHighlightsService) { self.container = container self.highlightsService = highlightsService - } - - // MARK: Public - public func build() -> (Page) -> PageView { let reading = ReadingPreferences.shared.reading - let readingDirectory = readingDirectory(reading) + let readingDirectory = Self.readingDirectory(reading, container: container) let imageService = ImageDataService( ayahInfoDatabase: reading.ayahInfoDatabase(in: readingDirectory), @@ -42,26 +38,29 @@ public struct ContentImageBuilder: PageViewBuilder { ) let pages = reading.quran.pages - let cacheableImageService = createCahceableImageService(imageService: imageService, pages: pages) - let cacheablePageMarkers = createPageMarkersService(imageService: imageService, reading: reading, pages: pages) - - return { page in - let controller = ContentImageViewController( - page: page, - dataService: cacheableImageService, - pageMarkerService: cacheablePageMarkers, - highlightsService: highlightsService - ) - return controller - } + cacheableImageService = Self.createCahceableImageService(imageService: imageService, pages: pages) + cacheablePageMarkers = Self.createPageMarkersService(imageService: imageService, reading: reading, pages: pages) + } + + // MARK: Public + + public func build(at page: Page) -> PageView { + ContentImageViewController( + page: page, + dataService: cacheableImageService, + pageMarkerService: cacheablePageMarkers, + highlightsService: highlightsService + ) } // MARK: Private private let container: AppDependencies private let highlightsService: QuranHighlightsService + private let cacheableImageService: PagesCacheableService + private let cacheablePageMarkers: PagesCacheableService? - private func readingDirectory(_ reading: Reading) -> URL { + private static func readingDirectory(_ reading: Reading, container: AppDependencies) -> URL { let remoteResource = container.remoteResources?.resource(for: reading) let remotePath = remoteResource?.downloadDestination.url let bundlePath = { Bundle.main.url(forResource: reading.localPath, withExtension: nil) } @@ -69,7 +68,7 @@ public struct ContentImageBuilder: PageViewBuilder { return remotePath ?? bundlePath()! } - private func createCahceableImageService(imageService: ImageDataService, pages: [Page]) -> PagesCacheableService { + private static func createCahceableImageService(imageService: ImageDataService, pages: [Page]) -> PagesCacheableService { let cache = Cache() cache.countLimit = 5 @@ -86,7 +85,7 @@ public struct ContentImageBuilder: PageViewBuilder { return dataService } - private func createPageMarkersService( + private static func createPageMarkersService( imageService: ImageDataService, reading: Reading, pages: [Page] diff --git a/Features/QuranPagesFeature/PageViewBuilder.swift b/Features/QuranPagesFeature/PageViewBuilder.swift index 9c9d46b2..c000ece9 100644 --- a/Features/QuranPagesFeature/PageViewBuilder.swift +++ b/Features/QuranPagesFeature/PageViewBuilder.swift @@ -11,7 +11,7 @@ import UIKit @MainActor public protocol PageViewBuilder { - func build() -> (Page) -> PageView + func build(at page: Page) -> PageView } @MainActor diff --git a/Features/QuranTranslationFeature/ContentTranslationBuilder.swift b/Features/QuranTranslationFeature/ContentTranslationBuilder.swift index 3aec18d2..da6fd3ae 100644 --- a/Features/QuranTranslationFeature/ContentTranslationBuilder.swift +++ b/Features/QuranTranslationFeature/ContentTranslationBuilder.swift @@ -23,25 +23,29 @@ public struct ContentTranslationBuilder: PageViewBuilder { public init(container: AppDependencies, highlightsService: QuranHighlightsService) { self.container = container self.highlightsService = highlightsService + + let reading = ReadingPreferences.shared.reading + let pages = reading.quran.pages + (dataService, selectedTranslationsMonitor) = Self.createElementLoader(pages: pages, container: container) } // MARK: Public - public func build() -> (Page) -> PageView { - let reading = ReadingPreferences.shared.reading - let pages = reading.quran.pages - let dataService = createElementLoader(pages: pages) - return { page in - ContentTranslationViewController(dataService: dataService, page: page, highlightsService: highlightsService) - } + public func build(at page: Page) -> PageView { + ContentTranslationViewController(dataService: dataService, page: page, highlightsService: highlightsService) } // MARK: Private private let container: AppDependencies private let highlightsService: QuranHighlightsService + private let dataService: PagesCacheableService + private let selectedTranslationsMonitor: SelectedTranslationsMonitor - private func createElementLoader(pages: [Page]) -> PagesCacheableService { + private static func createElementLoader( + pages: [Page], + container: AppDependencies + ) -> (PagesCacheableService, SelectedTranslationsMonitor) { let cache = Cache() cache.countLimit = 5 @@ -58,12 +62,15 @@ public struct ContentTranslationBuilder: PageViewBuilder { return TranslatedPage(translatedVerses: translatedVerses) } - return PagesCacheableService( + let service = PagesCacheableService( cache: cache, previousPagesCount: 1, nextPagesCount: 2, pages: pages, operation: operation ) + + let monitor = SelectedTranslationsMonitor(cache: cache) + return (service, monitor) } } diff --git a/Features/QuranTranslationFeature/SelectedTranslationsMonitor.swift b/Features/QuranTranslationFeature/SelectedTranslationsMonitor.swift new file mode 100644 index 00000000..627cf2ae --- /dev/null +++ b/Features/QuranTranslationFeature/SelectedTranslationsMonitor.swift @@ -0,0 +1,25 @@ +// +// SelectedTranslationsMonitor.swift +// +// +// Created by Mohamed Afifi on 2023-12-26. +// + +import Caching +import Combine +import QuranKit +import TranslationService + +final class SelectedTranslationsMonitor { + // MARK: Lifecycle + + init(cache: Cache) { + cancellable = selectedTranslationsPreferences.$selectedTranslations + .sink { _ in cache.removeAllObjects() } + } + + // MARK: Internal + + let selectedTranslationsPreferences = SelectedTranslationsPreferences.shared + var cancellable: AnyCancellable? +}