From 726015c560be0521a705ca358e05d19a9da68286 Mon Sep 17 00:00:00 2001 From: Mohamed Afifi Date: Sat, 5 Oct 2024 12:59:46 -0400 Subject: [PATCH 1/3] Create CocoaNavigationBar --- UI/UIx/SwiftUI/Views/CocoaNavigationBar.swift | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 UI/UIx/SwiftUI/Views/CocoaNavigationBar.swift diff --git a/UI/UIx/SwiftUI/Views/CocoaNavigationBar.swift b/UI/UIx/SwiftUI/Views/CocoaNavigationBar.swift new file mode 100644 index 00000000..bfe1dd89 --- /dev/null +++ b/UI/UIx/SwiftUI/Views/CocoaNavigationBar.swift @@ -0,0 +1,160 @@ +// +// CocoaNavigationBar.swift +// +// +// Created by Mohamed Afifi on 2024-09-29. +// + +import SwiftUI + +public struct BarButton { + public enum Content: Equatable { + case image(UIImage?, style: UIBarButtonItem.Style) + case system(UIBarButtonItem.SystemItem) + } + + let content: Content + let action: @MainActor () -> Void + + public init(_ content: Content, action: @escaping @MainActor () -> Void) { + self.content = content + self.action = action + } +} + +public struct CocoaNavigationBar: View { + let title: String + let leftButtons: [BarButton] + let rightButtons: [BarButton] + var prefersLargeTitles: Bool = false + var standardAppearance: UINavigationBarAppearance? + var scrollEdgeAppearance: UINavigationBarAppearance? + + public init(title: String, leftButtons: [BarButton], rightButtons: [BarButton]) { + self.title = title + self.leftButtons = leftButtons + self.rightButtons = rightButtons + } + + public var body: some View { + NavigationBarRepresentable( + title: title, + leftButtons: leftButtons, + rightButtons: rightButtons, + prefersLargeTitles: prefersLargeTitles, + standardAppearance: standardAppearance, + scrollEdgeAppearance: scrollEdgeAppearance + ) + // show the separator + .padding(.bottom, 1) + } + + public func prefersLargeTitles(_ prefersLargeTitles: Bool) -> Self { + mutateSelf { + $0.prefersLargeTitles = prefersLargeTitles + } + } + + public func standardAppearance(_ standardAppearance: UINavigationBarAppearance?) -> Self { + mutateSelf { + $0.standardAppearance = standardAppearance + } + } + + public func scrollEdgeAppearance(_ scrollEdgeAppearance: UINavigationBarAppearance?) -> Self { + mutateSelf { + $0.scrollEdgeAppearance = scrollEdgeAppearance + } + } +} + +private struct NavigationBarRepresentable: UIViewRepresentable { + let title: String + let leftButtons: [BarButton] + let rightButtons: [BarButton] + var prefersLargeTitles: Bool + var standardAppearance: UINavigationBarAppearance? + var scrollEdgeAppearance: UINavigationBarAppearance? + + func makeUIView(context: Context) -> NavigationBarView { + NavigationBarView() + } + + func updateUIView(_ view: NavigationBarView, context: Context) { + view.configure(title: title, leftButtons: leftButtons, rightButtons: rightButtons) + } + + class NavigationBarView: UINavigationBar { + private let item = UINavigationItem() + private var leftButtons: [BarButton] = [] + private var rightButtons: [BarButton] = [] + private var buttonActions: [UIBarButtonItem: @MainActor () -> Void] = [:] + + func configure( + title: String, + leftButtons: [BarButton], + rightButtons: [BarButton] + ) { + item.title = title + + let leftButtonsEquals = self.leftButtons.map(\.content) == leftButtons.map(\.content) + let rightButtonsEquals = self.rightButtons.map(\.content) == rightButtons.map(\.content) + + buttonActions.removeAll() + + let leftBarButtonItems = leftButtons.map { barButtonItem(of: $0) } + item.setLeftBarButtonItems(leftBarButtonItems, animated: !leftButtonsEquals && topItem === item) + let rightBarButtonItems = rightButtons.map { barButtonItem(of: $0) } + item.setRightBarButtonItems(rightBarButtonItems, animated: !rightButtonsEquals && topItem === item) + + self.leftButtons = leftButtons + self.rightButtons = rightButtons + + if topItem == nil { + setItems([item], animated: false) + } + } + + private func barButtonItem(of button: BarButton) -> UIBarButtonItem { + let buttonItem = switch button.content { + case .image(let image, let style): + UIBarButtonItem(image: image, style: style, target: self, action: #selector(buttonTapped)) + case .system(let systemItem): + UIBarButtonItem(barButtonSystemItem: systemItem, target: self, action: #selector(buttonTapped)) + } + buttonActions[buttonItem] = button.action + return buttonItem + } + + @objc + private func buttonTapped(_ buttonItem: UIBarButtonItem) { + let action = buttonActions[buttonItem] + action?() + } + } +} + +public extension UINavigationBarAppearance { + static func defaultBackground() -> UINavigationBarAppearance { + let appearance = UINavigationBarAppearance() + appearance.configureWithDefaultBackground() + return appearance + } + + static func opaqueBackground() -> UINavigationBarAppearance { + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + return appearance + } + + static func transparentBackground() -> UINavigationBarAppearance { + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + return appearance + } + + func backgroundColor(_ backgroundColor: UIColor?) -> Self { + self.backgroundColor = backgroundColor + return self + } +} From 158a9472a4391620976f4940ac8dfa945cc7bd33 Mon Sep 17 00:00:00 2001 From: Mohamed Afifi Date: Sat, 5 Oct 2024 13:01:00 -0400 Subject: [PATCH 2/3] Enhance CocoaNavigationView to listen to navigationItem changes --- .../SwiftUI/Views/CocoaNavigationView.swift | 161 ++++++++++++++++-- 1 file changed, 143 insertions(+), 18 deletions(-) diff --git a/UI/UIx/SwiftUI/Views/CocoaNavigationView.swift b/UI/UIx/SwiftUI/Views/CocoaNavigationView.swift index 286ce437..1bf1ebe2 100644 --- a/UI/UIx/SwiftUI/Views/CocoaNavigationView.swift +++ b/UI/UIx/SwiftUI/Views/CocoaNavigationView.swift @@ -10,10 +10,12 @@ import SwiftUI // Inspired by https://github.com/SwiftUIX/SwiftUIX/tree/master/Sources/Intramodular/Navigation // but can size itself inside a popover. +public protocol StackableViewController: UIViewController { } + public struct CocoaNavigationView: View { // MARK: Lifecycle - public init(rootConfiguration: NavigationConfiguration = NavigationConfiguration(), @ViewBuilder root: () -> Root) { + public init(rootConfiguration: NavigationConfiguration? = nil, @ViewBuilder root: () -> Root) { self.root = root() self.rootConfiguration = rootConfiguration } @@ -21,21 +23,42 @@ public struct CocoaNavigationView: View { // MARK: Public public var body: some View { - NavigationViewBody(root: root, rootConfiguration: rootConfiguration) - .edgesIgnoringSafeArea(.all) + NavigationViewBody( + root: root, + rootConfiguration: rootConfiguration, + prefersLargeTitles: prefersLargeTitles, + standardAppearance: standardAppearance, + scrollEdgeAppearance: scrollEdgeAppearance + ) + .edgesIgnoringSafeArea(.all) } // MARK: Private private let root: Root - private var rootConfiguration: NavigationConfiguration + private var rootConfiguration: NavigationConfiguration? + private var standardAppearance: UINavigationBarAppearance? + private var scrollEdgeAppearance: UINavigationBarAppearance? + private var prefersLargeTitles = false + + public func standardAppearance(_ standardAppearance: UINavigationBarAppearance) -> Self { + mutateSelf { + $0.standardAppearance = standardAppearance + } + } + + public func scrollEdgeAppearance(_ scrollEdgeAppearance: UINavigationBarAppearance) -> Self { + mutateSelf { + $0.scrollEdgeAppearance = scrollEdgeAppearance + } + } } public struct Navigator { // MARK: Public public func push( - configuration: NavigationConfiguration = NavigationConfiguration(), + configuration: NavigationConfiguration? = nil, animated: Bool = true, @ViewBuilder _ view: () -> some View ) { @@ -72,10 +95,18 @@ extension EnvironmentValues { public struct NavigationConfiguration { // MARK: Lifecycle - public init(navigationBarHidden: Bool = false, title: String? = nil, backgroundColor: UIColor? = nil) { + public init( + navigationBarHidden: Bool = false, + title: String? = nil, + backgroundColor: UIColor? = nil, + leftBarButtons: [BarButton] = [], + rightBarButtons: [BarButton] = [] + ) { self.navigationBarHidden = navigationBarHidden self.title = title self.backgroundColor = backgroundColor + self.leftBarButtons = leftBarButtons + self.rightBarButtons = rightBarButtons } // MARK: Public @@ -83,6 +114,8 @@ public struct NavigationConfiguration { public var navigationBarHidden: Bool public var title: String? public var backgroundColor: UIColor? + public var leftBarButtons: [BarButton] + public var rightBarButtons: [BarButton] } private struct NavigationViewBody: UIViewControllerRepresentable { @@ -93,13 +126,16 @@ private struct NavigationViewBody: UIViewControllerRepresentable { } let root: Root - let rootConfiguration: NavigationConfiguration + let rootConfiguration: NavigationConfiguration? + let prefersLargeTitles: Bool + let standardAppearance: UINavigationBarAppearance? + let scrollEdgeAppearance: UINavigationBarAppearance? func makeUIViewController(context: Context) -> CocoaNavigationController { let navigationController = CocoaNavigationController() - let root = root + let navigatorRoot = root .environment(\.navigator, Navigator(navigationController: navigationController)) - let controller = ElementController(rootView: root, configuration: rootConfiguration) + let controller = ElementController(rootView: AnyView(navigatorRoot), configuration: rootConfiguration) navigationController.setViewControllers([controller], animated: false) navigationController.delegate = context.coordinator navigationController.configuration = rootConfiguration @@ -107,10 +143,19 @@ private struct NavigationViewBody: UIViewControllerRepresentable { } func updateUIViewController(_ navigationController: CocoaNavigationController, context: Context) { - if let rootViewController = navigationController.viewControllers.first as? ElementController { - rootViewController.rootView = root + if let rootViewController = navigationController.viewControllers.first as? ElementController { + let navigatorRoot = root + .environment(\.navigator, Navigator(navigationController: navigationController)) + rootViewController.rootView = AnyView(navigatorRoot) rootViewController.configuration = rootConfiguration } + navigationController.navigationBar.prefersLargeTitles = prefersLargeTitles + if let standardAppearance { + navigationController.navigationBar.standardAppearance = standardAppearance + } + if let scrollEdgeAppearance { + navigationController.navigationBar.scrollEdgeAppearance = scrollEdgeAppearance + } } func makeCoordinator() -> Coordinator { @@ -119,9 +164,10 @@ private struct NavigationViewBody: UIViewControllerRepresentable { } private class CocoaNavigationController: UINavigationController { - var configuration = NavigationConfiguration(navigationBarHidden: false, title: "") { + var configuration: NavigationConfiguration? { didSet { - if configuration.navigationBarHidden != oldValue.navigationBarHidden { + guard let configuration else { return } + if configuration.navigationBarHidden != oldValue?.navigationBarHidden { if configuration.navigationBarHidden != isNavigationBarHidden { setNavigationBarHidden(configuration.navigationBarHidden, animated: true) } @@ -133,7 +179,7 @@ private class CocoaNavigationController: UINavigationController { get { super.isNavigationBarHidden } set { - guard !(configuration.navigationBarHidden && !newValue) else { + guard !((configuration?.navigationBarHidden ?? false) && !newValue) else { return } @@ -152,7 +198,7 @@ private class CocoaNavigationController: UINavigationController { guard hidden != isNavigationBarHidden else { return } - super.setNavigationBarHidden(configuration.navigationBarHidden, animated: animated) + super.setNavigationBarHidden(configuration?.navigationBarHidden ?? hidden, animated: animated) DispatchQueue.main.async { self.preferredContentSize = self.preferredContentSize } @@ -161,14 +207,14 @@ private class CocoaNavigationController: UINavigationController { override func viewWillAppear(_ animated: Bool) { view.backgroundColor = nil super.viewWillAppear(animated) - setNavigationBarHidden(configuration.navigationBarHidden, animated: false) + setNavigationBarHidden(configuration?.navigationBarHidden ?? false, animated: false) } } private class ElementController: UIHostingController { // MARK: Lifecycle - init(rootView: Content, configuration: NavigationConfiguration) { + init(rootView: Content, configuration: NavigationConfiguration?) { self.configuration = configuration super.init(rootView: rootView) configure() @@ -181,7 +227,7 @@ private class ElementController: UIHostingController { // MARK: Internal - var configuration: NavigationConfiguration { + var configuration: NavigationConfiguration? { didSet { cocoaNavigation?.configuration = configuration configure() @@ -202,14 +248,93 @@ private class ElementController: UIHostingController { preferredContentSize = container.preferredContentSize } + override func addChild(_ childController: UIViewController) { + super.addChild(childController) + if let mainElementVC = childController as? StackableViewController { + observeNavigationItem(of: mainElementVC) + } + } + // MARK: Private + private var childNavigationItemObservations: [NSKeyValueObservation]? + private var buttonActions: [UIBarButtonItem: @MainActor () -> Void] = [:] + private var cocoaNavigation: CocoaNavigationController? { navigationController as? CocoaNavigationController } + private func observeNavigationItem(of child: some StackableViewController) { + let options: NSKeyValueObservingOptions = [.new, .initial] + let action: (UIViewController) -> Void = { [weak self] childController in + self?.syncNavigationItem(with: childController.navigationItem) + } + childNavigationItemObservations = [ + observe(\.navigationItem.title, on: child, options: options, action: action), + observe(\.navigationItem.rightBarButtonItem, on: child, options: options, action: action), + observe(\.navigationItem.rightBarButtonItems, on: child, options: options, action: action), + observe(\.navigationItem.leftBarButtonItem, on: child, options: options, action: action), + observe(\.navigationItem.leftBarButtonItems, on: child, options: options, action: action), + ] + + if #available(iOS 16.0, *) { + childNavigationItemObservations?.append(contentsOf: [ + observe(\.navigationItem.leadingItemGroups, on: child, options: options, action: action), + observe(\.navigationItem.trailingItemGroups, on: child, options: options, action: action), + ]) + } + } + + private func observe( + _ keyPath: KeyPath, + on viewController: UIViewController, + options: NSKeyValueObservingOptions, + action: @escaping (UIViewController) -> Void + ) -> NSKeyValueObservation { + viewController.observe(keyPath, options: options) { viewController, _ in + action(viewController) + } + } + + private func syncNavigationItem(with navigationItem: UINavigationItem) { + self.navigationItem.title = navigationItem.title + self.navigationItem.leftBarButtonItems = navigationItem.leftBarButtonItems + self.navigationItem.rightBarButtonItems = navigationItem.rightBarButtonItems + + if #available(iOS 16.0, *) { + self.navigationItem.leadingItemGroups = navigationItem.leadingItemGroups + self.navigationItem.trailingItemGroups = navigationItem.trailingItemGroups + } + } + private func configure() { + guard let configuration else { + return + } title = configuration.title viewIfLoaded?.backgroundColor = configuration.backgroundColor + + buttonActions.removeAll() + let leftBarButtonItems = configuration.leftBarButtons.map { barButtonItem(of: $0) } + navigationItem.leftBarButtonItems = leftBarButtonItems + let rightBarButtonItems = configuration.rightBarButtons.map { barButtonItem(of: $0) } + navigationItem.rightBarButtonItems = rightBarButtonItems + } + + private func barButtonItem(of button: BarButton) -> UIBarButtonItem { + let buttonItem = switch button.content { + case .image(let image, let style): + UIBarButtonItem(image: image, style: style, target: self, action: #selector(barButtonTapped)) + case .system(let systemItem): + UIBarButtonItem(barButtonSystemItem: systemItem, target: self, action: #selector(barButtonTapped)) + } + buttonActions[buttonItem] = button.action + return buttonItem + } + + @objc + private func barButtonTapped(_ buttonItem: UIBarButtonItem) { + let action = buttonActions[buttonItem] + action?() } } From 4ce0addbf31931f9e92964e2bea262edb0cce725 Mon Sep 17 00:00:00 2001 From: Mohamed Afifi Date: Sat, 5 Oct 2024 13:23:54 -0400 Subject: [PATCH 3/3] Make Advanced Audio options use MVVM --- Core/QueuePlayer/Runs.swift | 3 +- .../AdvancedAudioOptionsBuilder.swift | 16 +- .../AdvancedAudioOptionsInteractor.swift | 186 ---------------- .../AdvancedAudioOptionsView.swift | 209 ++++++++++++++++++ .../AdvancedAudioOptionsViewController.swift | 176 --------------- .../AdvancedAudioOptionsViewModel.swift | 125 +++++++++++ .../AdvancedAudioVersesViewController.swift | 27 +-- .../AdvancedAudioOptionsFeature/Runs++.swift | 26 +++ .../AudioBannerViewModel.swift | 8 +- .../ReciterNavigationController.swift | 13 -- .../ReciterListBuilder.swift | 4 +- .../ReciterListFeature/ReciterListView.swift | 75 +++++-- .../ReciterListViewController.swift | 15 +- .../ReciterListViewModel.swift | 12 +- .../Components/ActiveRoundedButton.swift | 42 ++++ UI/NoorUI/Components/ChoicesView.swift | 29 +++ .../AdvancedAudioOptionsView.swift | 196 ---------------- .../AdvancedAudioUI.swift | 96 -------- .../Features/AyahMenu/AyahMenuView.swift | 2 +- 19 files changed, 514 insertions(+), 746 deletions(-) delete mode 100644 Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsInteractor.swift create mode 100644 Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsView.swift delete mode 100644 Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsViewController.swift create mode 100644 Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsViewModel.swift rename {UI/NoorUI/Features/AdvancedAudioOptions => Features/AdvancedAudioOptionsFeature}/AdvancedAudioVersesViewController.swift (64%) create mode 100644 Features/AdvancedAudioOptionsFeature/Runs++.swift delete mode 100644 Features/AudioBannerFeature/ReciterNavigationController.swift create mode 100644 UI/NoorUI/Components/ActiveRoundedButton.swift create mode 100644 UI/NoorUI/Components/ChoicesView.swift delete mode 100644 UI/NoorUI/Features/AdvancedAudioOptions/AdvancedAudioOptionsView.swift delete mode 100644 UI/NoorUI/Features/AdvancedAudioOptions/AdvancedAudioUI.swift diff --git a/Core/QueuePlayer/Runs.swift b/Core/QueuePlayer/Runs.swift index b42f2300..2e9db10b 100644 --- a/Core/QueuePlayer/Runs.swift +++ b/Core/QueuePlayer/Runs.swift @@ -17,7 +17,8 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // -public enum Runs: Equatable, Sendable { + +public enum Runs: Hashable, Sendable { case one case two case three diff --git a/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsBuilder.swift b/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsBuilder.swift index d2225309..ca5062ba 100644 --- a/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsBuilder.swift +++ b/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsBuilder.swift @@ -7,24 +7,22 @@ // import ReciterListFeature -import UIKit +import SwiftUI @MainActor public struct AdvancedAudioOptionsBuilder { - // MARK: Lifecycle - public init() { } - // MARK: Public - public func build(withListener listener: AdvancedAudioOptionsListener, options: AdvancedAudioOptions) -> UIViewController { - let viewModel = AdvancedAudioOptionsInteractor(options: options) - viewModel.listener = listener - let viewController = AdvancedAudioOptionsNavigationController( - viewModel: viewModel, + let viewModel = AdvancedAudioOptionsViewModel( + options: options, reciterListBuilder: ReciterListBuilder() ) + viewModel.listener = listener + + let view = AdvancedAudioOptionsView(viewModel: viewModel) + let viewController = UIHostingController(rootView: view) return viewController } } diff --git a/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsInteractor.swift b/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsInteractor.swift deleted file mode 100644 index c5a65949..00000000 --- a/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsInteractor.swift +++ /dev/null @@ -1,186 +0,0 @@ -// -// AdvancedAudioOptionsInteractor.swift -// Quran -// -// Created by Afifi, Mohamed on 3/31/19. -// Copyright © 2019 Quran.com. All rights reserved. -// - -import Combine -import NoorUI -import QueuePlayer -import QuranAudio -import QuranKit -import QuranTextKit - -@MainActor -public protocol AdvancedAudioOptionsListener: AnyObject { - func updateAudioOptions(to newOptions: AdvancedAudioOptions) - func dismissAudioOptions() -} - -@MainActor -final class AdvancedAudioOptionsInteractor { - // MARK: Lifecycle - - init(options: AdvancedAudioOptions) { - self.options = options - reciter = options.reciter - - let advancedAudioSuras = Self.surasToAdvancedAudioSuras(options.start.quran.suras) - dataObject = AdvancedAudioUI.DataObject( - suras: advancedAudioSuras, - fromVerse: Self.verseToAdvancedAudioVerse(options.start), - toVerse: Self.verseToAdvancedAudioVerse(options.end), - verseRepeat: AdvancedAudioUI.AudioRepeat(options.verseRuns), - listRepeat: AdvancedAudioUI.AudioRepeat(options.listRuns), - reciterName: options.reciter.localizedName - ) - } - - // MARK: Internal - - weak var listener: AdvancedAudioOptionsListener? - - @Published var dataObject: AdvancedAudioUI.DataObject - - func play() { - listener?.updateAudioOptions(to: currentOptions()) - dismiss() - } - - func dismiss() { - listener?.dismissAudioOptions() - } - - // MARK: - Updating Last Ayah - - func updateFromVerseTo(_ from: ConcreteAdvancedAudioUIVerse) { - dataObject.fromVerse = from - if dataObject.toVerse.verse < from.verse { - dataObject.toVerse = from - } - } - - func updateToVerseTo(_ to: ConcreteAdvancedAudioUIVerse) { - dataObject.toVerse = to - if to.verse < dataObject.fromVerse.verse { - dataObject.fromVerse = to - } - } - - func setLastVerseInPage() { - setLastVerse(using: PageBasedLastAyahFinder()) - } - - func setLastVerseInJuz() { - setLastVerse(using: JuzBasedLastAyahFinder()) - } - - func setLastVerseInSura() { - for sura in dataObject.suras { - if sura.verses.contains(dataObject.fromVerse) { - updateToVerseTo(sura.verses.last!) - } - } - } - - // MARK: - Reciter List - - func updateReciter(to reciter: Reciter) { - self.reciter = reciter - } - - // MARK: Private - - private let options: AdvancedAudioOptions - - private var reciter: Reciter { - didSet { - dataObject.reciterName = reciter.localizedName - } - } - - private static func surasToAdvancedAudioSuras(_ suras: [Sura]) -> [ConcreteAdvancedAudioUISura] { - var advancedAudioSuras: [ConcreteAdvancedAudioUISura] = [] - for sura in suras { - let verses = sura.verses - let advancedAudioVerses = verses.map { Self.verseToAdvancedAudioVerse($0) } - advancedAudioSuras.append(ConcreteAdvancedAudioUISura(sura: sura, verses: advancedAudioVerses)) - } - return advancedAudioSuras - } - - private static func verseToAdvancedAudioVerse(_ verse: AyahNumber) -> ConcreteAdvancedAudioUIVerse { - ConcreteAdvancedAudioUIVerse(verse: verse) - } - - private func setLastVerse(using finder: LastAyahFinder) { - let startVerse = dataObject.fromVerse.verse - let verse = finder.findLastAyah(startAyah: startVerse) - updateToVerseTo(Self.verseToAdvancedAudioVerse(verse)) - } - - private func currentOptions() -> AdvancedAudioOptions { - let from = dataObject.fromVerse.verse - let to = dataObject.toVerse.verse - return AdvancedAudioOptions( - reciter: reciter, - start: from, - end: to, - verseRuns: dataObject.verseRepeat.run, - listRuns: dataObject.listRepeat.run - ) - } -} - -private extension AdvancedAudioUI.AudioRepeat { - var run: Runs { - switch self { - case .none: - return .one - case .once: - return .two - case .twice: - return .three - case .indefinite: - return .indefinite - } - } -} - -private extension AdvancedAudioUI.AudioRepeat { - init(_ runs: Runs) { - switch runs { - case .one: - self = .none - case .two: - self = .once - case .three: - self = .twice - case .four, .indefinite: - self = .indefinite - } - } -} - -struct ConcreteAdvancedAudioUIVerse: Hashable, AdvancedAudioUIVerse { - let verse: AyahNumber - - var localizedName: String { - verse.localizedName - } - - var localizedNameWithSuraNumber: String { - verse.localizedNameWithSuraNumber - } -} - -struct ConcreteAdvancedAudioUISura: AdvancedAudioUISura { - let sura: Sura - let verses: [ConcreteAdvancedAudioUIVerse] - - var localizedName: String { - sura.localizedName() - } -} diff --git a/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsView.swift b/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsView.swift new file mode 100644 index 00000000..677c357b --- /dev/null +++ b/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsView.swift @@ -0,0 +1,209 @@ +// +// AdvancedAudioOptionsView.swift +// Quran +// +// Created by Afifi, Mohamed on 12/24/20. +// Copyright © 2020 Quran.com. All rights reserved. +// + +import Localization +import NoorUI +import QueuePlayer +import QuranKit +import SwiftUI +import UIx + +struct AdvancedAudioOptionsView: View { + @StateObject var viewModel: AdvancedAudioOptionsViewModel + + var body: some View { + CocoaNavigationView { + AdvancedAudioOptionsRootView(viewModel: viewModel) + } + .standardAppearance(.opaqueBackground().backgroundColor(.systemBackground)) + .scrollEdgeAppearance(.opaqueBackground().backgroundColor(.systemBackground)) + } +} + +struct AdvancedAudioOptionsRootView: View { + @StateObject var viewModel: AdvancedAudioOptionsViewModel + + var body: some View { + AdvancedAudioOptionsRootViewUI( + reciterName: viewModel.reciter.localizedName, + fromVerse: viewModel.fromVerse, + toVerse: viewModel.toVerse, + verseRuns: $viewModel.verseRuns, + listRuns: $viewModel.listRuns, + dismiss: { viewModel.dismiss() }, + play: { viewModel.play() }, + lastPageTapped: { viewModel.setLastVerseInPage() }, + lastSuraTapped: { viewModel.setLastVerseInSura() }, + lastJuzTapped: { viewModel.setLastVerseInJuz() }, + updateFromVerseTo: { viewModel.updateFromVerseTo($0) }, + updateToVerseTo: { viewModel.updateToVerseTo($0) }, + recitersViewController: { viewModel.recitersViewController() } + ) + } +} + +struct AdvancedAudioOptionsRootViewUI: View { + // MARK: Internal + + let reciterName: String + let fromVerse: AyahNumber + let toVerse: AyahNumber + @Binding var verseRuns: Runs + @Binding var listRuns: Runs + let dismiss: @MainActor @Sendable () -> Void + let play: @MainActor @Sendable () -> Void + let lastPageTapped: @MainActor @Sendable () -> Void + let lastSuraTapped: @MainActor @Sendable () -> Void + let lastJuzTapped: @MainActor @Sendable () -> Void + let updateFromVerseTo: ItemAction + let updateToVerseTo: ItemAction + let recitersViewController: () -> UIViewController + + @Environment(\.navigator) var navigator: Navigator? + + var body: some View { + Form { + ReciterSection(name: reciterName, image: nil) { + navigator?.push { + StaticViewControllerRepresentable(viewController: recitersViewController()) + } + } + + Section(header: Text(l("audio.adjust-end-verse-to-the-end.label"))) { + HStack { + ActiveRoundedButton(label: lAndroid("quran_page"), action: lastPageTapped) + Spacer() + ActiveRoundedButton(label: l("surah"), action: lastSuraTapped) + Spacer() + ActiveRoundedButton(label: lAndroid("quran_juz2"), action: lastJuzTapped) + } + } + + Section(header: Text(l("audio.playing-verses.label"))) { + VerseStaticView(label: lAndroid("from"), verse: fromVerse) { + navigator?.push { + StaticViewControllerRepresentable(viewController: fromVerseSelectionViewController) + } + } + VerseStaticView(label: lAndroid("to"), verse: toVerse) { + navigator?.push { + StaticViewControllerRepresentable(viewController: toVerseSelectionViewController) + } + } + } + + RunsChoicesSection(title: lAndroid("play_each_verse"), runs: $verseRuns) + RunsChoicesSection(title: lAndroid("play_verses_range"), runs: $listRuns) + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: dismiss) { + Text(lAndroid("cancel")) + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: play) { + NoorSystemImage.play.image + } + } + } + } + + // MARK: Private + + private var fromVerseSelectionViewController: UIViewController { + let verseSelection = AdvancedAudioVersesViewController(suras: fromVerse.quran.suras, selected: fromVerse) { fromVerse in + updateFromVerseTo(fromVerse) + navigator?.pop() + } + verseSelection.title = l("audio.select-start-verse") + return verseSelection + } + + private var toVerseSelectionViewController: UIViewController { + let verseSelection = AdvancedAudioVersesViewController(suras: toVerse.quran.suras, selected: toVerse) { toVerse in + updateFromVerseTo(toVerse) + navigator?.pop() + } + verseSelection.title = l("audio.select-start-verse") + return verseSelection + } +} + +private struct RunsChoicesSection: View { + let title: String + @Binding var runs: Runs + + var body: some View { + Section(header: Text(title.replacingOccurrences(of: ":", with: ""))) { + ChoicesView(items: Runs.sorted, selection: $runs) { + $0.localizedDescription + } + } + } +} + +private struct VerseStaticView: View { + let label: String + let verse: AyahNumber + let action: AsyncAction + + var body: some View { + NoorListItem( + title: .text(label), + subtitle: .init(text: verse.localizedNameWithSuraNumber, location: .trailing), + accessory: .disclosureIndicator, + action: action + ) + } +} + +private struct ReciterSection: View { + let name: String + let image: String? + let action: AsyncAction + + var body: some View { + Section { + NoorListItem( + title: .text(name), + accessory: .disclosureIndicator, + action: action + ) + } + } +} + +#Preview { + struct Container: View { + @State var verseRuns: Runs = .one + @State var listRuns: Runs = .three + + var body: some View { + AdvancedAudioOptionsRootViewUI( + reciterName: "Mishary", + fromVerse: Quran.hafsMadani1405.suras[0].firstVerse, + toVerse: Quran.hafsMadani1405.suras[0].lastVerse, + verseRuns: $verseRuns, + listRuns: $listRuns, + dismiss: {}, + play: {}, + lastPageTapped: {}, + lastSuraTapped: {}, + lastJuzTapped: {}, + updateFromVerseTo: { _ in }, + updateToVerseTo: { _ in }, + recitersViewController: { UIViewController() } + ) + } + } + + return Container() +} diff --git a/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsViewController.swift b/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsViewController.swift deleted file mode 100644 index d2c41417..00000000 --- a/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsViewController.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// AdvancedAudioOptionsViewController.swift -// Quran -// -// Created by Afifi, Mohamed on 2018-04-07. -// -// Quran for iOS is a Quran reading application for iOS. -// Copyright (C) 2018 Quran.com -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU 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 General Public License for more details. -// - -import Localization -import NoorUI -import QuranAudio -import ReciterListFeature -import SwiftUI - -class AdvancedAudioOptionsNavigationController: BaseNavigationController, ReciterListListener { - // MARK: Lifecycle - - init( - viewModel: AdvancedAudioOptionsInteractor, - reciterListBuilder: ReciterListBuilder - ) { - self.reciterListBuilder = reciterListBuilder - rootViewController = AdvancedAudioOptionsViewController(viewModel: viewModel) - super.init(rootViewController: rootViewController) - rootViewController.actions = AdvancedAudioOptionsViewController.Actions( - presentReciterList: { [weak self] in self?.presentReciterList() }, - showFromVerseSelection: { [weak self] in self?.showFromVerseSelection() }, - showToVerseSelection: { [weak self] in self?.showToVerseSelection() } - ) - rotateToPortraitIfPhone() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - traitCollection.userInterfaceIdiom == .pad ? .all : .portrait - } - - override func viewDidLoad() { - super.viewDidLoad() - navigationBar.prefersLargeTitles = false - let appearance = UINavigationBarAppearance() - appearance.configureWithOpaqueBackground() - appearance.backgroundColor = .systemBackground - navigationBar.standardAppearance = appearance - navigationBar.scrollEdgeAppearance = appearance - } - - func onSelectedReciterChanged(to reciter: Reciter) { - rootViewController.viewModel.updateReciter(to: reciter) - } - - func dismissReciterList() { - popViewController(animated: true) - } - - // MARK: Private - - private let rootViewController: AdvancedAudioOptionsViewController - private let reciterListBuilder: ReciterListBuilder - - private func showFromVerseSelection() { - let dataObject = rootViewController.viewModel.dataObject - let verseSelection = AdvancedAudioVersesViewController( - suras: dataObject.suras, - selected: dataObject.fromVerse - ) { [weak self] in - self?.rootViewController.viewModel.updateFromVerseTo($0) - self?.popViewController(animated: true) - } - verseSelection.title = l("audio.select-start-verse") - pushViewController(verseSelection, animated: true) - } - - private func showToVerseSelection() { - let dataObject = rootViewController.viewModel.dataObject - let verseSelection = AdvancedAudioVersesViewController( - suras: dataObject.suras, - selected: dataObject.toVerse - ) { [weak self] in - self?.rootViewController.viewModel.updateToVerseTo($0) - self?.popViewController(animated: true) - } - verseSelection.title = l("audio.select-end-verse") - pushViewController(verseSelection, animated: true) - } - - private func presentReciterList() { - let viewController = reciterListBuilder.build(withListener: self) - pushViewController(viewController, animated: true) - } -} - -class AdvancedAudioOptionsViewController: BaseViewController { - struct Actions { - var presentReciterList: () -> Void - var showFromVerseSelection: () -> Void - var showToVerseSelection: () -> Void - } - - // MARK: Lifecycle - - init(viewModel: AdvancedAudioOptionsInteractor) { - self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - var actions: Actions? - - let viewModel: AdvancedAudioOptionsInteractor - - override func viewDidLoad() { - super.viewDidLoad() - configureNavigationBar() - addAdvancedAudioView() - } - - @objc - func playButtonTapped() { - viewModel.play() - } - - @objc - func dismissView() { - viewModel.dismiss() - } - - func addAdvancedAudioView() { - let view = AdvancedAudioOptionsView(dataObject: viewModel.dataObject, actions: viewActions) - let viewController = UIHostingController(rootView: view) - addFullScreenChild(viewController) - } - - // MARK: Private - - private var viewActions: AdvancedAudioUI.Actions { - AdvancedAudioUI.Actions( - reciterTapped: { [weak self] in self?.actions?.presentReciterList() }, - lastPageTapped: { [weak self] in self?.viewModel.setLastVerseInPage() }, - lastSuraTapped: { [weak self] in self?.viewModel.setLastVerseInSura() }, - lastJuzTapped: { [weak self] in self?.viewModel.setLastVerseInJuz() }, - fromVerseTapped: { [weak self] in self?.actions?.showFromVerseSelection() }, - toVerseTapped: { [weak self] in self?.actions?.showToVerseSelection() } - ) - } - - private func configureNavigationBar() { - let playImage = UIImage.symbol("play.fill") - navigationItem.rightBarButtonItem = UIBarButtonItem(image: playImage, style: .done, target: self, action: #selector(playButtonTapped)) - navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(dismissView)) - } -} diff --git a/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsViewModel.swift b/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsViewModel.swift new file mode 100644 index 00000000..7e7e567c --- /dev/null +++ b/Features/AdvancedAudioOptionsFeature/AdvancedAudioOptionsViewModel.swift @@ -0,0 +1,125 @@ +// +// AdvancedAudioOptionsViewModel.swift +// Quran +// +// Created by Afifi, Mohamed on 3/31/19. +// Copyright © 2019 Quran.com. All rights reserved. +// + +import Combine +import NoorUI +import QueuePlayer +import QuranAudio +import QuranKit +import QuranTextKit +import ReciterListFeature +import SwiftUI + +@MainActor +public protocol AdvancedAudioOptionsListener: AnyObject { + func updateAudioOptions(to newOptions: AdvancedAudioOptions) + func dismissAudioOptions() +} + +@MainActor +final class AdvancedAudioOptionsViewModel: ObservableObject { + // MARK: Lifecycle + + init( + options: AdvancedAudioOptions, + reciterListBuilder: ReciterListBuilder + ) { + self.options = options + self.reciterListBuilder = reciterListBuilder + reciter = options.reciter + fromVerse = options.start + toVerse = options.end + verseRuns = options.verseRuns + listRuns = options.listRuns + } + + // MARK: Internal + + weak var listener: AdvancedAudioOptionsListener? + + @Published var fromVerse: AyahNumber + @Published var toVerse: AyahNumber + @Published var verseRuns: Runs + @Published var listRuns: Runs + @Published var reciter: Reciter + + var suras: [Sura] { + options.start.quran.suras + } + + func play() { + listener?.updateAudioOptions(to: currentOptions()) + dismiss() + } + + func dismiss() { + listener?.dismissAudioOptions() + } + + // MARK: - Updating Last Ayah + + func updateFromVerseTo(_ from: AyahNumber) { + fromVerse = from + if toVerse < from { + toVerse = from + } + } + + func updateToVerseTo(_ to: AyahNumber) { + toVerse = to + if to < fromVerse { + fromVerse = to + } + } + + func setLastVerseInPage() { + setLastVerse(using: PageBasedLastAyahFinder()) + } + + func setLastVerseInJuz() { + setLastVerse(using: JuzBasedLastAyahFinder()) + } + + func setLastVerseInSura() { + for sura in suras { + if sura.verses.contains(fromVerse) { + updateToVerseTo(sura.verses.last!) + } + } + } + + // MARK: Private + + private let options: AdvancedAudioOptions + private let reciterListBuilder: ReciterListBuilder + + private func setLastVerse(using finder: LastAyahFinder) { + let verse = finder.findLastAyah(startAyah: fromVerse) + updateToVerseTo(verse) + } + + private func currentOptions() -> AdvancedAudioOptions { + return AdvancedAudioOptions( + reciter: reciter, + start: fromVerse, + end: toVerse, + verseRuns: verseRuns, + listRuns: listRuns + ) + } +} + +extension AdvancedAudioOptionsViewModel: ReciterListListener { + func recitersViewController() -> UIViewController { + reciterListBuilder.build(withListener: self, standalone: false) + } + + func onSelectedReciterChanged(to reciter: Reciter) { + self.reciter = reciter + } +} diff --git a/UI/NoorUI/Features/AdvancedAudioOptions/AdvancedAudioVersesViewController.swift b/Features/AdvancedAudioOptionsFeature/AdvancedAudioVersesViewController.swift similarity index 64% rename from UI/NoorUI/Features/AdvancedAudioOptions/AdvancedAudioVersesViewController.swift rename to Features/AdvancedAudioOptionsFeature/AdvancedAudioVersesViewController.swift index ccb952cd..54267375 100644 --- a/UI/NoorUI/Features/AdvancedAudioOptions/AdvancedAudioVersesViewController.swift +++ b/Features/AdvancedAudioOptionsFeature/AdvancedAudioVersesViewController.swift @@ -5,12 +5,13 @@ // Created by Afifi, Mohamed on 10/10/21. // +import QuranKit import UIKit -public class AdvancedAudioVersesViewController: UITableViewController { +class AdvancedAudioVersesViewController: UITableViewController { // MARK: Lifecycle - public init(suras: [Sura], selected: Sura.Verse, onSelection: @escaping (Sura.Verse) -> Void) { + init(suras: [Sura], selected: AyahNumber, onSelection: @MainActor @escaping (AyahNumber) -> Void) { self.suras = suras self.selected = selected self.onSelection = onSelection @@ -22,9 +23,9 @@ public class AdvancedAudioVersesViewController: UITab fatalError("init(coder:) has not been implemented") } - // MARK: Public + // MARK: Internal - override public func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") tableView.rowHeight = UITableView.automaticDimension @@ -37,15 +38,15 @@ public class AdvancedAudioVersesViewController: UITab } } - override public func numberOfSections(in tableView: UITableView) -> Int { + override func numberOfSections(in tableView: UITableView) -> Int { suras.count } - override public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { suras[section].verses.count } - override public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) let verse = verseAtIndexPath(indexPath) cell.textLabel?.text = verse.localizedName @@ -54,19 +55,19 @@ public class AdvancedAudioVersesViewController: UITab return cell } - override public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - suras[section].localizedName + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + suras[section].localizedName() } - override public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { onSelection(verseAtIndexPath(indexPath)) } // MARK: Private - private let onSelection: (Sura.Verse) -> Void + private let onSelection: (AyahNumber) -> Void private let suras: [Sura] - private let selected: Sura.Verse + private let selected: AyahNumber private func selectedIndexPath() -> IndexPath? { for (section, sura) in suras.enumerated() { @@ -79,7 +80,7 @@ public class AdvancedAudioVersesViewController: UITab return nil } - private func verseAtIndexPath(_ indexPath: IndexPath) -> Sura.Verse { + private func verseAtIndexPath(_ indexPath: IndexPath) -> AyahNumber { suras[indexPath.section].verses[indexPath.item] } } diff --git a/Features/AdvancedAudioOptionsFeature/Runs++.swift b/Features/AdvancedAudioOptionsFeature/Runs++.swift new file mode 100644 index 00000000..753b309d --- /dev/null +++ b/Features/AdvancedAudioOptionsFeature/Runs++.swift @@ -0,0 +1,26 @@ +// +// Runs++.swift +// Quran +// +// Created by Afifi, Mohamed on 12/26/20. +// Copyright © 2020 Quran.com. All rights reserved. +// + +import Localization +import QueuePlayer + +extension Runs { + static var sorted: [Runs] { + [.one, .two, .three, .indefinite] + } + + var localizedDescription: String { + switch self { + case .one: return lAndroid("repeatValues[0]") + case .two: return lAndroid("repeatValues[1]") + case .three: return lAndroid("repeatValues[2]") + case .four: fatalError("Not implemented") + case .indefinite: return lAndroid("repeatValues[3]") + } + } +} diff --git a/Features/AudioBannerFeature/AudioBannerViewModel.swift b/Features/AudioBannerFeature/AudioBannerViewModel.swift index 1855541b..593c1afd 100644 --- a/Features/AudioBannerFeature/AudioBannerViewModel.swift +++ b/Features/AudioBannerFeature/AudioBannerViewModel.swift @@ -464,8 +464,7 @@ extension AudioBannerViewModel { extension AudioBannerViewModel: ReciterListListener { func presentReciterList() { logger.info("AudioBanner: reciters button tapped. State: \(playingState)") - let viewController = reciterListBuilder.build(withListener: self) - viewControllerToPresent = ReciterNavigationController(rootViewController: viewController) + viewControllerToPresent = reciterListBuilder.build(withListener: self, standalone: true) } public func onSelectedReciterChanged(to reciter: Reciter) { @@ -473,11 +472,6 @@ extension AudioBannerViewModel: ReciterListListener { selectReciter(reciter) playingState = .stopped } - - public func dismissReciterList() { - logger.info("AudioBanner: dismiss reciters list") - dismissPresentedViewController = true - } } extension AudioBannerViewModel: AdvancedAudioOptionsListener { diff --git a/Features/AudioBannerFeature/ReciterNavigationController.swift b/Features/AudioBannerFeature/ReciterNavigationController.swift deleted file mode 100644 index a5e85e75..00000000 --- a/Features/AudioBannerFeature/ReciterNavigationController.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// ReciterNavigationController.swift -// Quran -// -// Created by Mohamed Afifi on 5/12/16. -// -// Quran for iOS is a Quran reading application for iOS. -// Copyright (C) 2017 Quran.com -// - -import NoorUI - -class ReciterNavigationController: BaseNavigationController {} diff --git a/Features/ReciterListFeature/ReciterListBuilder.swift b/Features/ReciterListFeature/ReciterListBuilder.swift index 905c4c7c..bf71c34f 100644 --- a/Features/ReciterListFeature/ReciterListBuilder.swift +++ b/Features/ReciterListFeature/ReciterListBuilder.swift @@ -16,8 +16,8 @@ public struct ReciterListBuilder { // MARK: Public @MainActor - public func build(withListener listener: ReciterListListener) -> UIViewController { - let viewModel = ReciterListViewModel() + public func build(withListener listener: ReciterListListener, standalone: Bool) -> UIViewController { + let viewModel = ReciterListViewModel(standalone: standalone) let viewController = ReciterListViewController(viewModel: viewModel) viewModel.listener = listener return viewController diff --git a/Features/ReciterListFeature/ReciterListView.swift b/Features/ReciterListFeature/ReciterListView.swift index 5f199068..c464b459 100644 --- a/Features/ReciterListFeature/ReciterListView.swift +++ b/Features/ReciterListFeature/ReciterListView.swift @@ -10,12 +10,14 @@ import NoorUI import QuranAudio import SwiftUI import UIx +import VLogging struct ReciterListView: View { @StateObject var viewModel: ReciterListViewModel var body: some View { ReciterListViewUI( + standalone: viewModel.standalone, recentReciters: viewModel.recentReciters, downloadedReciters: viewModel.downloadedReciters, englishReciters: viewModel.englishReciters, @@ -30,6 +32,9 @@ struct ReciterListView: View { private struct ReciterListViewUI: View { // MARK: Internal + @Environment(\.dismiss) var dismiss + + let standalone: Bool let recentReciters: [Reciter] let downloadedReciters: [Reciter] let englishReciters: [Reciter] @@ -41,6 +46,24 @@ private struct ReciterListViewUI: View { let selectAction: ItemAction var body: some View { + Group { + if standalone { + CocoaNavigationView { + content + .background(Color.blue) + } + .background(Color.yellow) + } else { + content + .background(Color.green) + } + } + .background(Color.red) + } + + // MARK: Private + + private var content: some View { NoorList { NoorSection( title: l("reciters.recent"), @@ -75,10 +98,20 @@ private struct ReciterListViewUI: View { ) } .task(start) + .navigationTitle(l("reciters.title")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + logger.info("Reciters: dismiss reciters list tapped") + dismiss() + } label: { + Text(l("button.done")) + .font(.headline) + } + } + } } - // MARK: Private - private func allTitle(languageCode: String) -> String { if let language = Locale.fixedCurrentLocaleNumbers.localizedString(forLanguageCode: languageCode) { return l("reciters.all") + " (" + language.capitalized + ")" @@ -92,7 +125,9 @@ private struct ReciterListViewUI: View { title: .text(reciter.localizedName), accessory: reciter == selectedReciter ? .image(.checkmark, color: .appIdentity) : nil ) { + logger.info("Reciters: reciter selected \(reciter.id)") selectAction(reciter) + dismiss() } } } @@ -102,27 +137,20 @@ struct ReciterListView_Previews: PreviewProvider { @State var selectedReciter: Reciter? var body: some View { - NavigationView { - ReciterListViewUI( - recentReciters: [reciter(id: 1), reciter(id: 2)], - downloadedReciters: [reciter(id: 1), reciter(id: 3), reciter(id: 10), reciter(id: 12)], - englishReciters: (1 ... 9).map { reciter(id: $0) }, - arabicReciters: (10 ... 20).map { reciter(id: $0) }, - selectedReciter: selectedReciter, - start: {}, - selectAction: { selectedReciter = $0 } - ) - .navigationTitle("Reciters") - .toolbar { - Button("Clear selection") { - selectedReciter = nil - } - } - } + ReciterListViewUI( + standalone: true, + recentReciters: [reciter(id: 1), reciter(id: 2)], + downloadedReciters: [reciter(id: 1), reciter(id: 3), reciter(id: 10), reciter(id: 12)], + englishReciters: (1 ... 9).map { reciter(id: $0) }, + arabicReciters: (10 ... 20).map { reciter(id: $0) }, + selectedReciter: selectedReciter, + start: {}, + selectAction: { selectedReciter = $0 } + ) } func reciter(id: Int) -> Reciter { - let name = "reciter" + String(id) + let name = "Reciter " + String(id) return Reciter( id: id, nameKey: name, @@ -138,8 +166,9 @@ struct ReciterListView_Previews: PreviewProvider { // MARK: Internal static var previews: some View { - VStack { - Preview() - } + VStack {} + .sheet(isPresented: .constant(true)) { + Preview() + } } } diff --git a/Features/ReciterListFeature/ReciterListViewController.swift b/Features/ReciterListFeature/ReciterListViewController.swift index c9715cad..009d6e2a 100644 --- a/Features/ReciterListFeature/ReciterListViewController.swift +++ b/Features/ReciterListFeature/ReciterListViewController.swift @@ -5,11 +5,10 @@ // Created by Mohamed Afifi on 2023-07-25. // -import Localization import SwiftUI import UIx -final class ReciterListViewController: UIHostingController { +final class ReciterListViewController: UIHostingController, StackableViewController { // MARK: Lifecycle init(viewModel: ReciterListViewModel) { @@ -29,19 +28,7 @@ final class ReciterListViewController: UIHostingController { traitCollection.userInterfaceIdiom == .pad ? .all : .portrait } - override func viewDidLoad() { - super.viewDidLoad() - title = l("reciters.title") - navigationItem.largeTitleDisplayMode = .never - navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(cancelButtonTapped)) - } - // MARK: Private private let viewModel: ReciterListViewModel - - @objc - private func cancelButtonTapped() { - viewModel.dismissRecitersList() - } } diff --git a/Features/ReciterListFeature/ReciterListViewModel.swift b/Features/ReciterListFeature/ReciterListViewModel.swift index 20e45779..6dc8e1c6 100644 --- a/Features/ReciterListFeature/ReciterListViewModel.swift +++ b/Features/ReciterListFeature/ReciterListViewModel.swift @@ -13,18 +13,19 @@ import VLogging @MainActor public protocol ReciterListListener: AnyObject { func onSelectedReciterChanged(to reciter: Reciter) - func dismissReciterList() } @MainActor final class ReciterListViewModel: ObservableObject { // MARK: Lifecycle - init() { + init(standalone: Bool) { + self.standalone = standalone } // MARK: Internal + let standalone: Bool weak var listener: ReciterListListener? @Published var recentReciters: [Reciter] = [] @@ -51,14 +52,7 @@ final class ReciterListViewModel: ObservableObject { } func selectReciter(_ reciter: Reciter) { - logger.info("Reciters: reciter selected \(reciter.id)") listener?.onSelectedReciterChanged(to: reciter) - listener?.dismissReciterList() - } - - func dismissRecitersList() { - logger.info("Reciters: dismiss reciters list tapped") - listener?.dismissReciterList() } // MARK: Private diff --git a/UI/NoorUI/Components/ActiveRoundedButton.swift b/UI/NoorUI/Components/ActiveRoundedButton.swift new file mode 100644 index 00000000..f1514827 --- /dev/null +++ b/UI/NoorUI/Components/ActiveRoundedButton.swift @@ -0,0 +1,42 @@ +// +// ActiveRoundedButton.swift +// +// +// Created by Mohamed Afifi on 2024-09-29. +// + +import SwiftUI +import UIx + +public struct ActiveRoundedButton: View { + let label: String + let action: AsyncAction + + public init(label: String, action: @escaping AsyncAction) { + self.label = label + self.action = action + } + + public var body: some View { + AsyncButton(action: action) { + Text(label) + .foregroundColor(.white) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .background( + RoundedActiveBackground(cornerRadius: 100) + ) + } + .buttonStyle(.borderless) + } +} + +private struct RoundedActiveBackground: View { + let cornerRadius: CGFloat + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.appIdentity.opacity(0.8)) + .shadow(color: .systemGray3, radius: 2) + } +} diff --git a/UI/NoorUI/Components/ChoicesView.swift b/UI/NoorUI/Components/ChoicesView.swift new file mode 100644 index 00000000..d1abcc07 --- /dev/null +++ b/UI/NoorUI/Components/ChoicesView.swift @@ -0,0 +1,29 @@ +// +// ChoicesView.swift +// +// +// Created by Mohamed Afifi on 2024-09-29. +// + +import SwiftUI + +public struct ChoicesView: View { + let items: [Item] + @Binding var selection: Item + let itemLabel: (Item) -> String + + public init(items: [Item], selection: Binding, itemLabel: @escaping (Item) -> String) { + self.items = items + _selection = selection + self.itemLabel = itemLabel + } + + public var body: some View { + Picker("", selection: $selection) { + ForEach(items, id: \.self) { item in + Text(itemLabel(item)) + } + } + .pickerStyle(SegmentedPickerStyle()) + } +} diff --git a/UI/NoorUI/Features/AdvancedAudioOptions/AdvancedAudioOptionsView.swift b/UI/NoorUI/Features/AdvancedAudioOptions/AdvancedAudioOptionsView.swift deleted file mode 100644 index 4cb3641a..00000000 --- a/UI/NoorUI/Features/AdvancedAudioOptions/AdvancedAudioOptionsView.swift +++ /dev/null @@ -1,196 +0,0 @@ -// -// AdvancedAudioOptionsView.swift -// Quran -// -// Created by Afifi, Mohamed on 12/24/20. -// Copyright © 2020 Quran.com. All rights reserved. -// - -import Localization -import SwiftUI -import UIx - -public struct AdvancedAudioOptionsView: View { - // MARK: Lifecycle - - public init(dataObject: AdvancedAudioUI.DataObject, actions: AdvancedAudioUI.Actions) { - _dataObject = ObservedObject(initialValue: dataObject) - self.actions = actions - } - - // MARK: Public - - public var body: some View { - Form { - Section { - ReciterView(name: dataObject.reciterName, image: nil, action: actions.reciterTapped) - } - - Section(header: Text(l("audio.adjust-end-verse-to-the-end.label"))) { - HStack { - LastVerseButton(label: lAndroid("quran_page"), action: actions.lastPageTapped) - Spacer() - LastVerseButton(label: l("surah"), action: actions.lastSuraTapped) - Spacer() - LastVerseButton(label: lAndroid("quran_juz2"), action: actions.lastJuzTapped) - } - } - - Section(header: Text(l("audio.playing-verses.label"))) { - // From - VerseStaticView(label: lAndroid("from"), verse: dataObject.fromVerse, action: actions.fromVerseTapped) - // To - VerseStaticView(label: lAndroid("to"), verse: dataObject.toVerse, action: actions.toVerseTapped) - } - - Section(header: Text(lAndroid("play_each_verse").replacingOccurrences(of: ":", with: ""))) { - RepeatView(items: AdvancedAudioUI.AudioRepeat.sorted, selection: $dataObject.verseRepeat) - } - - Section(header: Text(lAndroid("play_verses_range").replacingOccurrences(of: ":", with: ""))) { - RepeatView(items: AdvancedAudioUI.AudioRepeat.sorted, selection: $dataObject.listRepeat) - } - } - } - - // MARK: Internal - - @ObservedObject var dataObject: AdvancedAudioUI.DataObject - - // MARK: Private - - private let actions: AdvancedAudioUI.Actions -} - -private struct LastVerseButton: View { - let label: String - let action: AsyncAction - - var body: some View { - AsyncButton(action: action) { - Text(label) - .foregroundColor(.white) - .padding(.vertical, 5) - .padding(.horizontal, 10) - .background( - RoundedActiveBackground(cornerRadius: 100) - ) - } - .buttonStyle(.borderless) - } -} - -private struct RoundedActiveBackground: View { - let cornerRadius: CGFloat - - var body: some View { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(Color.appIdentity.opacity(0.8)) - .shadow(color: .systemGray3, radius: 2) - } -} - -private struct RepeatView: View { - let items: [AdvancedAudioUI.AudioRepeat] - @Binding var selection: AdvancedAudioUI.AudioRepeat - - var body: some View { - Picker("", selection: $selection) { - ForEach(items, id: \.self) { item in - Text(item.localizedDescription) - } - } - .pickerStyle(SegmentedPickerStyle()) - } -} - -private struct VerseStaticView: View { - let label: String - let verse: Verse - let action: AsyncAction - - var body: some View { - NoorListItem( - title: .text(label), - subtitle: .init(text: verse.localizedNameWithSuraNumber, location: .trailing), - accessory: .disclosureIndicator, - action: action - ) - } -} - -private struct ReciterView: View { - let name: String - let image: String? - let action: AsyncAction - - var body: some View { - NoorListItem( - title: .text(name), - accessory: .disclosureIndicator, - action: action - ) - } -} - -private extension AdvancedAudioUI.AudioRepeat { - var localizedDescription: String { - switch self { - case .none: return lAndroid("repeatValues[0]") - case .once: return lAndroid("repeatValues[1]") - case .twice: return lAndroid("repeatValues[2]") - case .indefinite: return lAndroid("repeatValues[3]") - } - } -} - -struct AdvancedAudioOptionsView_Previews: PreviewProvider { - struct PreviewSura: AdvancedAudioUISura { - var localizedName: String - var verses: [PreviewVerse] - } - - struct PreviewVerse: AdvancedAudioUIVerse { - var localizedName: String - var localizedNameWithSuraNumber: String - } - - struct Container: View { - static let actions = AdvancedAudioUI.Actions( - reciterTapped: {}, - lastPageTapped: {}, - lastSuraTapped: {}, - lastJuzTapped: {}, - fromVerseTapped: {}, - toVerseTapped: {} - ) - - @ObservedObject var dataObject = AdvancedAudioUI.DataObject( - suras: [PreviewSura( - localizedName: "Sura-1", - verses: [PreviewVerse(localizedName: "1-1", localizedNameWithSuraNumber: "1-2")] - )], - fromVerse: PreviewVerse( - localizedName: "An-Nas, Ayah 1", - localizedNameWithSuraNumber: "114. An-Nas, Ayah 1" - ), - toVerse: PreviewVerse( - localizedName: "An-Nas, Ayah 3", - localizedNameWithSuraNumber: "114. An-Nas, Ayah 3" - ), - verseRepeat: .none, - listRepeat: .twice, - reciterName: "Mishary" - ) - - var body: some View { - AdvancedAudioOptionsView(dataObject: dataObject, actions: Self.actions) - } - } - - // MARK: Internal - - static var previews: some View { - Container() - } -} diff --git a/UI/NoorUI/Features/AdvancedAudioOptions/AdvancedAudioUI.swift b/UI/NoorUI/Features/AdvancedAudioOptions/AdvancedAudioUI.swift deleted file mode 100644 index ae515729..00000000 --- a/UI/NoorUI/Features/AdvancedAudioOptions/AdvancedAudioUI.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// AdvancedAudioUI.swift -// Quran -// -// Created by Afifi, Mohamed on 12/26/20. -// Copyright © 2020 Quran.com. All rights reserved. -// - -import Combine -import UIx - -public protocol AdvancedAudioUISura { - associatedtype Verse: AdvancedAudioUIVerse - var localizedName: String { get } - var verses: [Verse] { get } -} - -public protocol AdvancedAudioUIVerse: Equatable { - var localizedName: String { get } - var localizedNameWithSuraNumber: String { get } -} - -public enum AdvancedAudioUI { - public struct Actions { - // MARK: Lifecycle - - public init( - reciterTapped: @escaping AsyncAction, - lastPageTapped: @escaping AsyncAction, - lastSuraTapped: @escaping AsyncAction, - lastJuzTapped: @escaping AsyncAction, - fromVerseTapped: @escaping AsyncAction, - toVerseTapped: @escaping AsyncAction - ) { - self.reciterTapped = reciterTapped - self.lastPageTapped = lastPageTapped - self.lastSuraTapped = lastSuraTapped - self.lastJuzTapped = lastJuzTapped - self.fromVerseTapped = fromVerseTapped - self.toVerseTapped = toVerseTapped - } - - // MARK: Internal - - let reciterTapped: AsyncAction - let lastPageTapped: AsyncAction - let lastSuraTapped: AsyncAction - let lastJuzTapped: AsyncAction - let fromVerseTapped: AsyncAction - let toVerseTapped: AsyncAction - } - - // MARK: Public - - public enum AudioRepeat: Int { - case none - case once - case twice - case indefinite - - // MARK: Internal - - static var sorted: [AudioRepeat] { - [.none, .once, .twice, .indefinite] - } - } - - public class DataObject: ObservableObject { - // MARK: Lifecycle - - public init( - suras: [Sura], - fromVerse: Sura.Verse, - toVerse: Sura.Verse, - verseRepeat: AdvancedAudioUI.AudioRepeat, - listRepeat: AdvancedAudioUI.AudioRepeat, - reciterName: String - ) { - self.suras = suras - _fromVerse = Published(initialValue: fromVerse) - _toVerse = Published(initialValue: toVerse) - _verseRepeat = Published(initialValue: verseRepeat) - _listRepeat = Published(initialValue: listRepeat) - _reciterName = Published(initialValue: reciterName) - } - - // MARK: Public - - public let suras: [Sura] - @Published public var fromVerse: Sura.Verse - @Published public var toVerse: Sura.Verse - @Published public var verseRepeat: AdvancedAudioUI.AudioRepeat - @Published public var listRepeat: AdvancedAudioUI.AudioRepeat - @Published public var reciterName: String - } -} diff --git a/UI/NoorUI/Features/AyahMenu/AyahMenuView.swift b/UI/NoorUI/Features/AyahMenu/AyahMenuView.swift index b5ff44e2..95cd4056 100644 --- a/UI/NoorUI/Features/AyahMenu/AyahMenuView.swift +++ b/UI/NoorUI/Features/AyahMenu/AyahMenuView.swift @@ -111,7 +111,7 @@ private struct AyahMenuViewList: View { subtitle: dataObject.playSubtitle, action: dataObject.actions.play ) { - Image(systemName: "play.fill") + NoorSystemImage.play.image } Divider() Row(