From 588ebd69b716d2d48e987d7911842d3e8e9ae5ae Mon Sep 17 00:00:00 2001 From: Mohamed Afifi Date: Sat, 28 Sep 2024 13:36:01 -0400 Subject: [PATCH 1/5] Implement a Toast library --- UI/UIx/SwiftUI/Toast/Toast.swift | 348 ++++++++++++++++++ .../SwiftUI/Toast/ToastEnvironmentKey.swift | 73 ++++ 2 files changed, 421 insertions(+) create mode 100644 UI/UIx/SwiftUI/Toast/Toast.swift create mode 100644 UI/UIx/SwiftUI/Toast/ToastEnvironmentKey.swift diff --git a/UI/UIx/SwiftUI/Toast/Toast.swift b/UI/UIx/SwiftUI/Toast/Toast.swift new file mode 100644 index 00000000..c0de29dc --- /dev/null +++ b/UI/UIx/SwiftUI/Toast/Toast.swift @@ -0,0 +1,348 @@ +// +// Toast.swift +// +// +// Created by Mohamed Afifi on 2024-09-22. +// + +// MARK: - Toast + +import Foundation +import SwiftUI +import UIKit + +public struct ToastAction { + public let title: String + public let handler: () -> Void + + public init(title: String, handler: @escaping () -> Void) { + self.title = title + self.handler = handler + } +} + +public struct Toast { + public let message: String + public let action: ToastAction? + public let duration: TimeInterval + public let bottomOffset: CGFloat + + public init( + _ message: String, + action: ToastAction? = nil, + duration: TimeInterval = 4.0, + bottomOffset: CGFloat = 40 + ) { + self.message = message + self.action = action + self.duration = duration + self.bottomOffset = bottomOffset + } +} + +private struct ToastView: View { + let message: String + let action: ToastAction? + let dismiss: () -> Void + @ScaledMetric var shadowRadius = 5 + + var body: some View { + HStack { + Text(message) + .foregroundColor(.systemBackground) + Spacer() + if let action { + Button(action.title) { + action.handler() + dismiss() + } + .foregroundColor(.systemBackground) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.label.opacity(0.8)) + .shadow(color: .label.opacity(0.33), radius: shadowRadius) + ) + .padding(.horizontal) + } +} + +private class ToastHostingController: UIHostingController { + init(toast: Toast, dismiss: @escaping () -> Void) { + let rootView = ToastView( + message: toast.message, + action: toast.action, + dismiss: dismiss + ) + super.init(rootView: rootView) + view.backgroundColor = .clear + } + + @available(*, unavailable) + @objc + dynamic required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private class ToastContainerViewController: UIViewController { + // MARK: Lifecycle + + init(toastViewController: UIViewController, bottomOffset: CGFloat) { + self.toastViewController = toastViewController + self.bottomOffset = bottomOffset + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Internal + + class PassThroughView: UIView { + // Allow touches to pass through except for the ToastView + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hitView = super.hitTest(point, with: event) + if hitView === self || hitView == nil { + return nil + } + return hitView + } + } + + var dismissCompletion: (() -> Void)? + + override func loadView() { + view = PassThroughView() + } + + override func viewDidLoad() { + super.viewDidLoad() + + // Allow touches outside the toast to pass through + view.backgroundColor = .clear + + // Add the toast view controller as a child + addChild(toastViewController) + view.addAutoLayoutSubview(toastViewController.view) + + // Setup constraints + toastViewController.view.vc.horizontalEdges() + setUpHideToastConstraints() + + toastViewController.didMove(toParent: self) + + // Add a swipe down gesture recognizer for manual dismissal + let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeDown)) + swipeGesture.direction = .down + toastViewController.view.addGestureRecognizer(swipeGesture) + + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanPanGesture(_:))) + toastViewController.view.addGestureRecognizer(panGesture) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + showToast() + } + + func showToast() { + toastViewController.view.layoutIfNeeded() + setUpShowToastConstraints() + + // Animate the toast into view + UIView.animate(withDuration: animationDuration, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0) { + self.view.layoutIfNeeded() + } + } + + func dismissToast(completion: (() -> Void)? = nil) { + setUpHideToastConstraints() + + // Animate the toast into view + UIView.animate(withDuration: animationDuration, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, animations: { + self.view.layoutIfNeeded() + }, completion: { _ in + self.toastViewController.willMove(toParent: nil) + self.toastViewController.view.removeFromSuperview() + self.toastViewController.removeFromParent() + self.dismiss(animated: false) { + self.dismissCompletion?() + completion?() + } + }) + } + + // MARK: Private + + private let toastViewController: UIViewController + private let bottomOffset: CGFloat + private let animationDuration: CGFloat = 0.3 + + private var activeConstraint: NSLayoutConstraint? { + didSet { + oldValue?.isActive = false + activeConstraint?.isActive = true + } + } + + private func setUpShowToastConstraints() { + activeConstraint = view.bottomAnchor.constraint(equalTo: toastViewController.view.bottomAnchor, constant: bottomOffset) + } + + private func setUpHideToastConstraints() { + activeConstraint = view.bottomAnchor.constraint(equalTo: toastViewController.view.topAnchor) + } + + @objc + private func handleSwipeDown() { + dismissToast() + } + + @objc + private func handlePanPanGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: gesture.view) + + switch gesture.state { + case .changed: + // Move the view with the pan gesture + if translation.y > 0 { // Allow dragging only downward + activeConstraint?.constant = bottomOffset - translation.y + } + case .ended: + if translation.y > bottomOffset { + dismissToast() + } else { + showToast() + } + default: + break + } + } +} + +private class ToastWindow: UIWindow { + override init(windowScene: UIWindowScene) { + super.init(windowScene: windowScene) + windowLevel = UIWindow.Level.statusBar + 1 + backgroundColor = .clear + isHidden = false + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // Allow touches to pass through except for the ToastView + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hitView = super.hitTest(point, with: event) + if hitView === self || hitView == nil { + return nil + } + return hitView + } +} + +@MainActor +public class ToastPresenter { + // MARK: Lifecycle + + private init() {} + + // MARK: Public + + public static let shared = ToastPresenter() + + public func showToast(_ toast: Toast, in windowScene: UIWindowScene) { + DispatchQueue.main.async { + self.queue.append((toast: toast, windowScene: windowScene)) + self.displayNextToast() + } + } + + public func dismissCurrentToast() { + guard let id = currentToastID else { return } + dismissToast(id: id) + } + + // MARK: Private + + private var queue: [(toast: Toast, windowScene: UIWindowScene)] = [] + private var isShowing = false + private var currentToastWindow: ToastWindow? + private var currentContainerVC: ToastContainerViewController? + private var currentToastID: UUID? + + private func displayNextToast() { + guard !isShowing, let nextToast = queue.first else { return } + isShowing = true + present(toast: nextToast.toast, in: nextToast.windowScene) + } + + private func present(toast: Toast, in windowScene: UIWindowScene) { + let toastID = UUID() + currentToastID = toastID + + let toastVC = ToastHostingController(toast: toast, dismiss: { [weak self] in + self?.dismissToast(id: toastID) + }) + + let containerVC = ToastContainerViewController( + toastViewController: toastVC, + bottomOffset: toast.bottomOffset + ) + + containerVC.dismissCompletion = { [weak self] in + self?.toastDidDismiss(id: toastID) + } + + let toastWindow = ToastWindow(windowScene: windowScene) + toastWindow.rootViewController = containerVC + toastWindow.makeKeyAndVisible() + + currentToastWindow = toastWindow + currentContainerVC = containerVC + + // Schedule automatic dismissal + DispatchQueue.main.asyncAfter(deadline: .now() + toast.duration) { [weak self] in + self?.dismissToast(id: toastID) + } + } + + private func toastDidDismiss(id: UUID) { + guard currentToastID == id else { return } + currentToastWindow?.isHidden = true + currentToastWindow = nil + currentContainerVC = nil + currentToastID = nil + isShowing = false + queue.removeFirst() + displayNextToast() + } + + private func dismissToast(id: UUID) { + DispatchQueue.main.async { + guard self.currentToastID == id else { return } + self.currentContainerVC?.dismissToast() + } + } +} + +#Preview { + VStack { + Spacer() + ToastView( + message: "This is a toast message", + action: ToastAction(title: "Dismiss", handler: {}), + dismiss: {} + ) + .padding(.bottom, 40) + } +} diff --git a/UI/UIx/SwiftUI/Toast/ToastEnvironmentKey.swift b/UI/UIx/SwiftUI/Toast/ToastEnvironmentKey.swift new file mode 100644 index 00000000..af9fb53e --- /dev/null +++ b/UI/UIx/SwiftUI/Toast/ToastEnvironmentKey.swift @@ -0,0 +1,73 @@ +// +// ToastEnvironmentKey.swift +// +// +// Created by Mohamed Afifi on 2024-09-22. +// + +import SwiftUI +import VLogging + +extension EnvironmentValues { + public var showToast: ((Toast) -> Void)? { + get { self[ToastPresenterKey.self] } + set { self[ToastPresenterKey.self] = newValue } + } +} + +extension View { + public func enableToastPresenter() -> some View { + modifier(ToastPresenterModifier()) + } +} + +private struct ToastPresenterKey: EnvironmentKey { + static let defaultValue: ((Toast) -> Void)? = nil +} + +private struct ToastPresenterModifier: ViewModifier { + @State private var windowScene: UIWindowScene? + + func body(content: Content) -> some View { + content + .background( + WindowSceneReader(windowScene: $windowScene) + ) + .onPreferenceChange(WindowScenePreferenceKey.self) { windowScene in + self.windowScene = windowScene + } + .environment(\.showToast) { toast in + if let windowScene { + ToastPresenter.shared.showToast(toast, in: windowScene) + } else { + logger.error("Failed to obtain windowScene") + } + } + } +} + +private struct WindowScenePreferenceKey: PreferenceKey { + static var defaultValue: UIWindowScene? = nil + + static func reduce(value: inout UIWindowScene?, nextValue: () -> UIWindowScene?) { + value = value ?? nextValue() + } +} + +private struct WindowSceneReader: UIViewRepresentable { + @Binding var windowScene: UIWindowScene? + + func makeUIView(context: Context) -> UIView { + let view = UIView() + DispatchQueue.main.async { + windowScene = view.window?.windowScene + } + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + DispatchQueue.main.async { + windowScene = uiView.window?.windowScene + } + } +} From 060c11a1f5ba761967f0c1e60e0cf6d06a0e1149 Mon Sep 17 00:00:00 2001 From: Mohamed Afifi Date: Sat, 28 Sep 2024 13:36:22 -0400 Subject: [PATCH 2/5] Add taskOnce view modifier --- UI/UIx/SwiftUI/Miscellaneous/View+Task.swift | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 UI/UIx/SwiftUI/Miscellaneous/View+Task.swift diff --git a/UI/UIx/SwiftUI/Miscellaneous/View+Task.swift b/UI/UIx/SwiftUI/Miscellaneous/View+Task.swift new file mode 100644 index 00000000..868d128a --- /dev/null +++ b/UI/UIx/SwiftUI/Miscellaneous/View+Task.swift @@ -0,0 +1,40 @@ +// +// View+Task.swift +// +// +// Created by Mohamed Afifi on 2024-09-27. +// + +import SwiftUI + +public struct TaskOnceModifier: ViewModifier { + @State private var started = false + private let priority: TaskPriority + private let action: @Sendable () async -> Void + + public init(priority: TaskPriority, _ action: @escaping @Sendable () async -> Void) { + self.priority = priority + self.action = action + } + + public func body(content: Content) -> some View { + content + .task(priority: priority) { + guard !started else { + return + } + started = true + await action() + } + } +} + +extension View { + @inlinable + public func taskOnce( + priority: TaskPriority = .userInitiated, + _ action: @escaping @Sendable () async -> Void + ) -> some View { + modifier(TaskOnceModifier(priority: priority, action)) + } +} From 717fbacc762415dd2e679d9486c865e9d89459ba Mon Sep 17 00:00:00 2001 From: Mohamed Afifi Date: Sat, 28 Sep 2024 13:36:51 -0400 Subject: [PATCH 3/5] Add UIKitNavigator to retrieve the containing view controller --- .../Miscellaneous/UIKitNavigator.swift | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 UI/UIx/SwiftUI/Miscellaneous/UIKitNavigator.swift diff --git a/UI/UIx/SwiftUI/Miscellaneous/UIKitNavigator.swift b/UI/UIx/SwiftUI/Miscellaneous/UIKitNavigator.swift new file mode 100644 index 00000000..a63c7169 --- /dev/null +++ b/UI/UIx/SwiftUI/Miscellaneous/UIKitNavigator.swift @@ -0,0 +1,76 @@ +// +// UIKitNavigator.swift +// +// +// Created by Mohamed Afifi on 2024-09-27. +// + +import SwiftUI + +public final class UIKitNavigator { + public weak var viewController: UIViewController? + var navigationController: UINavigationController? { + if let navController = viewController as? UINavigationController { + navController + } else { + viewController?.navigationController + } + } +} + +struct UINavigatorKey: EnvironmentKey { + static var defaultValue: UIKitNavigator? = nil +} + +extension EnvironmentValues { + public var uikitNavigator: UIKitNavigator? { + get { self[UINavigatorKey.self] } + set { self[UINavigatorKey.self] = newValue } + } +} + +extension View { + public func enableUIKitNavigator() -> some View { + modifier(EnableUIKitNavigator()) + } +} + +private struct EnableUIKitNavigator: ViewModifier { + @State var navigator = UIKitNavigator() + + func body(content: Content) -> some View { + content + .background(UIViewControllerReader(navigator: navigator)) + .environment(\.uikitNavigator, navigator) + } +} + +private struct UIViewControllerReader: UIViewControllerRepresentable { + let navigator: UIKitNavigator + + func makeUIViewController(context: Context) -> UIViewController { + ViewControllerReader(navigator: navigator) + } + + func updateUIViewController(_ viewController: UIViewController, context: Context) { + } + + private class ViewControllerReader: UIViewController { + let navigator: UIKitNavigator + + init(navigator: UIKitNavigator) { + self.navigator = navigator + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + navigator.viewController = parent + } + } +} From 5424de39de7181c8f1114ecbb2e4ef66fe63bede Mon Sep 17 00:00:00 2001 From: Mohamed Afifi Date: Sat, 28 Sep 2024 13:37:07 -0400 Subject: [PATCH 4/5] Add a generic custom button style --- .../Miscellaneous/CustomButtonStyle.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 UI/UIx/SwiftUI/Miscellaneous/CustomButtonStyle.swift diff --git a/UI/UIx/SwiftUI/Miscellaneous/CustomButtonStyle.swift b/UI/UIx/SwiftUI/Miscellaneous/CustomButtonStyle.swift new file mode 100644 index 00000000..a0a395d3 --- /dev/null +++ b/UI/UIx/SwiftUI/Miscellaneous/CustomButtonStyle.swift @@ -0,0 +1,20 @@ +// +// CustomButtonStyle.swift +// +// +// Created by Mohamed Afifi on 2024-09-22. +// + +import SwiftUI + +public struct CustomButtonStyle: ButtonStyle { + private let customize: (Configuration) -> Content + + public init(@ViewBuilder customize: @escaping (Configuration) -> Content) { + self.customize = customize + } + + public func makeBody(configuration: Configuration) -> some View { + customize(configuration) + } +} From c6ed48bc2d1d3d433546c07578dbca5decb12d5d Mon Sep 17 00:00:00 2001 From: Mohamed Afifi Date: Sat, 28 Sep 2024 13:37:41 -0400 Subject: [PATCH 5/5] Migrate AudioBanner to SwiftUI --- .../AudioBannerBuilder.swift | 11 +- .../AudioBannerFeature/AudioBannerView.swift | 60 ++++ .../AudioBannerViewController.swift | 311 ---------------- .../AudioBannerViewModel.swift | 334 +++++++++++------- .../AudioDownloadingBarView.swift | 39 -- .../AudioDownloadingBarView.xib | 78 ---- .../AudioBannerFeature/AudioPlayBarView.swift | 39 -- .../AudioBannerFeature/AudioPlayBarView.xib | 113 ------ .../AudioReciterBarView.swift | 41 --- .../AudioReciterBarView.xib | 113 ------ Package.swift | 1 + .../AudioBanner/AudioBannerViewUI.swift | 233 ++++++++++++ UI/NoorUI/Images/NoorSystemImage.swift | 7 + 13 files changed, 511 insertions(+), 869 deletions(-) create mode 100644 Features/AudioBannerFeature/AudioBannerView.swift delete mode 100644 Features/AudioBannerFeature/AudioBannerViewController.swift delete mode 100644 Features/AudioBannerFeature/AudioDownloadingBarView.swift delete mode 100644 Features/AudioBannerFeature/AudioDownloadingBarView.xib delete mode 100644 Features/AudioBannerFeature/AudioPlayBarView.swift delete mode 100644 Features/AudioBannerFeature/AudioPlayBarView.xib delete mode 100644 Features/AudioBannerFeature/AudioReciterBarView.swift delete mode 100644 Features/AudioBannerFeature/AudioReciterBarView.xib create mode 100644 UI/NoorUI/Features/AudioBanner/AudioBannerViewUI.swift diff --git a/Features/AudioBannerFeature/AudioBannerBuilder.swift b/Features/AudioBannerFeature/AudioBannerBuilder.swift index 0a580ff4..6ab71b38 100644 --- a/Features/AudioBannerFeature/AudioBannerBuilder.swift +++ b/Features/AudioBannerFeature/AudioBannerBuilder.swift @@ -11,6 +11,7 @@ import AppDependencies import QuranAudioKit import ReciterListFeature import ReciterService +import SwiftUI import UIKit @MainActor @@ -33,13 +34,15 @@ public struct AudioBannerBuilder { baseURL: container.filesAppHost, downloader: container.downloadManager ), - remoteCommandsHandler: RemoteCommandsHandler(center: .shared()) - ) - let viewController = AudioBannerViewController( - viewModel: viewModel, + remoteCommandsHandler: RemoteCommandsHandler(center: .shared()), reciterListBuilder: ReciterListBuilder(), advancedAudioOptionsBuilder: AdvancedAudioOptionsBuilder() ) + let view = AudioBannerView(viewModel: viewModel) + .enableToastPresenter() + .enableUIKitNavigator() + let viewController = UIHostingController(rootView: view) + viewController.view.backgroundColor = nil viewModel.listener = listener return (viewController, viewModel) } diff --git a/Features/AudioBannerFeature/AudioBannerView.swift b/Features/AudioBannerFeature/AudioBannerView.swift new file mode 100644 index 00000000..86933cda --- /dev/null +++ b/Features/AudioBannerFeature/AudioBannerView.swift @@ -0,0 +1,60 @@ +// +// AudioBannerView.swift +// +// +// Created by Mohamed Afifi on 2024-09-23. +// + +import NoorUI +import SwiftUI +import UIx + +struct AudioBannerView: View { + @StateObject var viewModel: AudioBannerViewModel + @Environment(\.showToast) private var showToast + @Environment(\.uikitNavigator) private var navigator + @ScaledMetric private var toastOffset = 100 + + var body: some View { + let actions = AudioBannerActions( + play: { viewModel.playFromBanner() }, + pause: { viewModel.pauseFromBanner() }, + resume: { viewModel.resumeFromBanner() }, + stop: { viewModel.stopFromBanner() }, + backward: { viewModel.backwardFromBanner() }, + forward: { viewModel.forwardFromBanner() }, + cancelDownloading: { await viewModel.cancelDownload() }, + reciters: { viewModel.presentReciterList() }, + more: { viewModel.showAdvancedAudioOptions() } + ) + AudioBannerViewUI( + state: viewModel.audioBannerState, + actions: actions + ) + .onChange(of: viewModel.toast?.message) { _ in + if let toast = viewModel.toast { + viewModel.toast = nil + showToast?(Toast(toast.message, action: toast.action, bottomOffset: toastOffset)) + } + } + .onChange(of: viewModel.viewControllerToPresent) { _ in + if let presentingVC = viewModel.viewControllerToPresent { + viewModel.viewControllerToPresent = nil + navigator?.viewController?.present(presentingVC, animated: true) + } + } + .onChange(of: viewModel.dismissPresentedViewController) { _ in + if viewModel.dismissPresentedViewController { + viewModel.dismissPresentedViewController = false + navigator?.viewController?.dismiss(animated: true) + } + } + .errorAlert(error: $viewModel.error) + .taskOnce { + await viewModel.start() + } + .onDisappear { + ToastPresenter.shared.dismissCurrentToast() + } + } +} diff --git a/Features/AudioBannerFeature/AudioBannerViewController.swift b/Features/AudioBannerFeature/AudioBannerViewController.swift deleted file mode 100644 index c9ab30cb..00000000 --- a/Features/AudioBannerFeature/AudioBannerViewController.swift +++ /dev/null @@ -1,311 +0,0 @@ -// -// AudioBannerViewController.swift -// Quran -// -// Created by Afifi, Mohamed on 4/7/19. -// Copyright © 2019 Quran.com. All rights reserved. -// - -import AdvancedAudioOptionsFeature -import Combine -import Localization -import NoorUI -import QuranAudio -import ReciterListFeature -import UIKit -import VLogging - -private let viewHeight: CGFloat = 48 - -final class AudioBannerViewController: UIViewController, AdvancedAudioOptionsListener, ReciterListListener { - // MARK: Lifecycle - - init( - viewModel: AudioBannerViewModel, - reciterListBuilder: ReciterListBuilder, - advancedAudioOptionsBuilder: AdvancedAudioOptionsBuilder - ) { - self.viewModel = viewModel - self.reciterListBuilder = reciterListBuilder - self.advancedAudioOptionsBuilder = advancedAudioOptionsBuilder - super.init(nibName: nil, bundle: nil) - viewModel.internalActions = AudioBannerViewModelInternalActions( - showError: { [weak self] in self?.showErrorAlert(error: $0) }, - playingStarted: { [weak self] in self?.playingStarted() }, - willStartDownloading: { [weak self] in self?.willStartDownloading() } - ) - - Task { - await viewModel.start() - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Internal - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = nil - view.layer.shadowOpacity = 0.6 - view.layer.shadowRadius = 2 - view.layer.shadowOffset = .zero - view.layer.shadowColor = UIColor.systemGray.cgColor - - view.addAutoLayoutSubview(visualEffect) - visualEffect.vc.edges() - - let contentView = UIView() - visualEffect.contentView.addAutoLayoutSubview(contentView) - contentView.vc - .height(by: viewHeight) - .horizontalEdges() - .top() - bottomConstraint = contentView.vc.bottom(usesMargins: true).constraint - - visualEffect.contentView.addAutoLayoutSubview(reciterView) - for view in [playView, downloadView] { - contentView.addAutoLayoutSubview(view) - } - - for view in [reciterView, playView, downloadView] { - view.vc.edges() - - view.backgroundColor = nil - view.alpha = 0 - } - - setUpReciterView() - setUpPlayView() - setUpDownloadView() - hideAllControls() - - listenToPlayingStateChanges() - } - - func hideAllControls() { - logger.info("AudioBanner: hideAllControls") - loadViewIfNeeded() - [reciterView, playView, downloadView].forEach { $0.alpha = 0 } - } - - func setReciter(name: String) { - reciterView.imageView.isHidden = true - reciterView.titleLabel.text = name - - hideAllExcept(reciterView) - } - - func setDownloading(_ progress: Float) { - logger.info("AudioBanner: downloading \(progress)") - downloadView.progressView.progress = progress - - hideAllExcept(downloadView) - } - - func setPlaying() { - logger.info("AudioBanner: setPlaying") - playView.pauseResumeButton.setImage(.symbol("pause.fill"), for: UIControl.State()) - - hideAllExcept(playView) - } - - func setPaused() { - logger.info("AudioBanner: setPaused") - playView.pauseResumeButton.setImage(.symbol("play.fill"), for: UIControl.State()) - - hideAllExcept(playView) - } - - func updateAudioOptions(to newOptions: AdvancedAudioOptions) { - logger.info("AudioBanner: updateAudioOptions") - viewModel.updateAudioOptions(to: newOptions) - } - - func dismissAudioOptions() { - logger.info("AudioBanner: dismiss advanced audio options") - dismiss(animated: true) - } - - // MARK: - Reciter List - - func onSelectedReciterChanged(to reciter: Reciter) { - logger.info("AudioBanner: onSelectedReciterChanged to \(reciter.id)") - viewModel.onSelectedReciterChanged(to: reciter) - } - - func dismissReciterList() { - logger.info("AudioBanner: dismiss reciters list") - dismiss(animated: true) - } - - // MARK: Private - - private let viewModel: AudioBannerViewModel - private let reciterListBuilder: ReciterListBuilder - private let advancedAudioOptionsBuilder: AdvancedAudioOptionsBuilder - private var cancellables: Set = [] - - private var bottomConstraint: NSLayoutConstraint? - - private let visualEffect = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) - - private let reciterView = AudioReciterBarView() - private let playView = AudioPlayBarView() - private let downloadView = AudioDownloadingBarView() - - private func listenToPlayingStateChanges() { - viewModel.$playingState.sink { [weak self] playingState in - switch playingState { - case .playing: self?.setPlaying() - case .paused: self?.setPaused() - case .stopped: self?.showReciterView() - case .downloading(let progress): self?.setDownloading(progress) - } - } - .store(in: &cancellables) - } - - private func setUpReciterView() { - reciterView.playButton.addTarget(self, action: #selector(reciterPlayTapped), for: .touchUpInside) - reciterView.backgroundButton.addTarget(self, action: #selector(reciterTapped), for: .touchUpInside) - reciterView.moreButton?.addTarget(self, action: #selector(showAdvancedAudioOptionsNotPlaying), for: .touchUpInside) - reciterView.backgroundButton.accessibilityLabel = "Reciter banner" - } - - private func setUpPlayView() { - playView.stopButton.addTarget(self, action: #selector(stopPlayingTapped), for: .touchUpInside) - playView.pauseResumeButton.addTarget(self, action: #selector(onPauseResumeTapped), for: .touchUpInside) - playView.nextButton.addTarget(self, action: #selector(nextTapped), for: .touchUpInside) - playView.previousButton.addTarget(self, action: #selector(previousTapped), for: .touchUpInside) - playView.moreButton?.addTarget(self, action: #selector(showAdvancedAudioOptions), for: .touchUpInside) - } - - private func setUpDownloadView() { - downloadView.cancelButton.addTarget(self, action: #selector(cancelDownloadTapped), for: .touchUpInside) - } - - private func showReciterView() { - logger.info("AudioBanner: show reciter view \(String(describing: viewModel.selectedReciter?.id))") - guard let selectedReciter = viewModel.selectedReciter else { - logger.info("AudioBanner: No reciter selected") - return - } - setReciter(name: selectedReciter.localizedName) - viewModel.showReciterView() - } - - private func hideAllExcept(_ view: UIView) { - UIView.animate(withDuration: 0.25, animations: { - for subview in [self.reciterView, self.playView, self.downloadView] { - subview.alpha = subview == view ? 1 : 0 - } - }) - } - - @objc - private func reciterTapped() { - logger.info("AudioBanner: reciters button tapped. State: \(viewModel.playingState)") - let viewController = reciterListBuilder.build(withListener: self) - presentReciterList(viewController) - } - - @objc - private func reciterPlayTapped() { - viewModel.onPlayTapped() - } - - @objc - private func stopPlayingTapped() { - viewModel.onStopTapped() - } - - @objc - private func onPauseResumeTapped() { - viewModel.onPauseResumeTapped() - } - - @objc - private func previousTapped() { - viewModel.onBackwardTapped() - } - - @objc - private func nextTapped() { - viewModel.onForwardTapped() - } - - @objc - private func cancelDownloadTapped() { - Task { - await viewModel.cancelDownload() - } - } - - // MARK: - Alerts - - private func willStartDownloading() { - if let audioRange = viewModel.audioRange { - let message = audioMessage("audio.downloading.message", audioRange: audioRange) - showDownloadingMessage(message) - } - } - - private func showDownloadingMessage(_ message: String) { - let alert = AlertViewController(message: message) - alert.show(autoHideAfter: 2) - } - - private func playingStarted() { - if let audioRange = viewModel.audioRange { - let message = audioMessage("audio.playing.message", audioRange: audioRange) - showPlayingMessage(message) - } - } - - private func audioMessage(_ format: String, audioRange: AudioBannerViewModel.AudioRange) -> String { - lFormat(format, audioRange.start.localizedName, audioRange.end.localizedName) - } - - private func showPlayingMessage(_ message: String) { - let alert = AlertViewController(message: message) - alert.addAction(l("audio.playing.action.modify")) { [weak self] in - self?.showAdvancedAudioOptions() - } - alert.addAction(lAndroid("dialog_ok")) - alert.show(autoHideAfter: 3) - } - - // MARK: - Advanced Audio Options - - @objc - private func showAdvancedAudioOptions() { - logger.info("AudioBanner: more button tapped. State: \(viewModel.playingState)") - guard let options = viewModel.advancedAudioOptions else { - logger.info("AudioBanner: showAdvancedAudioOptions couldn't construct advanced audio options") - return - } - let viewController = advancedAudioOptionsBuilder.build(withListener: self, options: options) - present(viewController, animated: true) - } - - @objc - private func showAdvancedAudioOptionsNotPlaying() { - logger.info("AudioBanner: more button tapped. State: \(viewModel.playingState)") - guard let options = viewModel.advancedAudioOptionsNotPlaying else { - logger.info("AudioBanner: showAdvancedAudioOptionsNotPlaying couldn't construct advanced audio options") - return - } - let viewController = advancedAudioOptionsBuilder.build(withListener: self, options: options) - present(viewController, animated: true) - } - - private func presentReciterList(_ viewController: UIViewController) { - let reciterNavigation = ReciterNavigationController(rootViewController: viewController) - present(reciterNavigation, animated: true, completion: nil) - } -} diff --git a/Features/AudioBannerFeature/AudioBannerViewModel.swift b/Features/AudioBannerFeature/AudioBannerViewModel.swift index afa8c4b6..9a8049cf 100644 --- a/Features/AudioBannerFeature/AudioBannerViewModel.swift +++ b/Features/AudioBannerFeature/AudioBannerViewModel.swift @@ -11,12 +11,17 @@ import Analytics import BatchDownloader import Crashing import Foundation +import Localization +import NoorUI import QueuePlayer import QuranAudio import QuranAudioKit import QuranKit +import ReciterListFeature import ReciterService +import SwiftUI import UIKit +import UIx import Utilities import VLogging @@ -26,21 +31,15 @@ public protocol AudioBannerListener: AnyObject { func highlightReadingAyah(_ ayah: AyahNumber?) } -enum PlaybackState { +private enum PlaybackState { case playing case paused case stopped - case downloading(progress: Float) -} - -struct AudioBannerViewModelInternalActions { - let showError: (Error) -> Void - let playingStarted: () -> Void - let willStartDownloading: () -> Void + case downloading(progress: Double) } @MainActor -public final class AudioBannerViewModel: RemoteCommandsHandlerDelegate { +public final class AudioBannerViewModel: ObservableObject { typealias AudioRange = (start: AyahNumber, end: AyahNumber) // MARK: Lifecycle @@ -51,7 +50,9 @@ public final class AudioBannerViewModel: RemoteCommandsHandlerDelegate { recentRecitersService: RecentRecitersService, audioPlayer: QuranAudioPlayer, downloader: QuranAudioDownloader, - remoteCommandsHandler: RemoteCommandsHandler + remoteCommandsHandler: RemoteCommandsHandler, + reciterListBuilder: ReciterListBuilder, + advancedAudioOptionsBuilder: AdvancedAudioOptionsBuilder ) { self.analytics = analytics self.reciterRetreiver = reciterRetreiver @@ -59,6 +60,8 @@ public final class AudioBannerViewModel: RemoteCommandsHandlerDelegate { self.audioPlayer = audioPlayer self.downloader = downloader self.remoteCommandsHandler = remoteCommandsHandler + self.reciterListBuilder = reciterListBuilder + self.advancedAudioOptionsBuilder = advancedAudioOptionsBuilder let actions = QuranAudioPlayerActions( playbackEnded: { [weak self] in self?.playbackEnded() }, @@ -87,39 +90,19 @@ public final class AudioBannerViewModel: RemoteCommandsHandlerDelegate { // MARK: Internal weak var listener: AudioBannerListener? - var internalActions: AudioBannerViewModelInternalActions? - - var audioRange: AudioRange? - - @Published var playingState: PlaybackState = .stopped - var advancedAudioOptionsNotPlaying: AdvancedAudioOptions? { - setAudioRangeForCurrentPage() - return advancedAudioOptions - } - - var advancedAudioOptions: AdvancedAudioOptions? { - guard let audioRange, let selectedReciter else { - return nil - } - return AdvancedAudioOptions( - reciter: selectedReciter, - start: audioRange.start, - end: audioRange.end, - verseRuns: verseRuns, - listRuns: listRuns - ) - } + @Published var error: Error? + @Published var toast: (message: String, action: ToastAction?)? + @Published var viewControllerToPresent: UIViewController? + @Published var dismissPresentedViewController = false - var selectedReciter: Reciter? { - let storedSelectedReciterId = preferences.lastSelectedReciterId - let selectedReciter = reciters.first { $0.id == storedSelectedReciterId } - if selectedReciter == nil { - let firstReciter = reciters.first - logger.error("AudioBanner: couldn't find reciter \(storedSelectedReciterId) using \(String(describing: firstReciter?.id)) instead") - return firstReciter + var audioBannerState: AudioBannerState { + switch playingState { + case .playing: .playing(paused: false) + case .paused: .playing(paused: true) + case .stopped: .readyToPlay(reciter: selectedReciter?.localizedName ?? "") + case .downloading(let progress): .downloading(progress: progress) } - return selectedReciter } func start() async { @@ -148,86 +131,47 @@ public final class AudioBannerViewModel: RemoteCommandsHandlerDelegate { } } - // MARK: - Advanced Options + // MARK: Private - func updateAudioOptions(to newOptions: AdvancedAudioOptions) { - logger.info("AudioBanner: playing advanced audio options \(newOptions)") - selectReciter(newOptions.reciter) - play(from: newOptions.start, to: newOptions.end, verseRuns: newOptions.verseRuns, listRuns: newOptions.listRuns) - } + private var audioRange: AudioRange? - func onSelectedReciterChanged(to reciter: Reciter) { - logger.info("AudioBanner: select reciter") - selectReciter(reciter) - playingState = .stopped - } + private let analytics: AnalyticsLibrary + private let reciterRetreiver: ReciterDataRetriever + private let recentRecitersService: RecentRecitersService + private let preferences = ReciterPreferences.shared + private let lastAyahFinder: LastAyahFinder = PreferencesLastAyahFinder.shared + private let audioPlayer: QuranAudioPlayer + private let downloader: QuranAudioDownloader + private let remoteCommandsHandler: RemoteCommandsHandler + private let reciterListBuilder: ReciterListBuilder + private let advancedAudioOptionsBuilder: AdvancedAudioOptionsBuilder - // MARK: - Remote Commands + private var verseRuns: Runs = .one + private var listRuns: Runs = .one + private var reciters: [Reciter] = [] + private var cancellableTasks: Set = [] - func onPlayCommandFired() { - logger.info("AudioBanner: play command fired. State: \(playingState)") - switch playingState { - case .stopped: playStartingCurrentPage() - case .paused, .playing: resume() - case .downloading: break + @Published private var playingState: PlaybackState = .stopped { + didSet { + logger.info("AudioBanner: playingState updated to \(playingState) - reciter: \(String(describing: selectedReciter?.id))") + if case .stopped = playingState { + onPlayingStateStopped() + } } } - func onPauseCommandFired() { - logger.info("AudioBanner: pause command fired. State: \(playingState)") - pause() - } - - func onTogglePlayPauseCommandFired() { - logger.info("AudioBanner: toggle play/pause command fired. State: \(playingState)") - togglePlayPause() - } - - func onStepForwardCommandFired() { - logger.info("AudioBanner: step forward command fired. State: \(playingState)") - stepForward() - } - - func onStepBackwardCommandFire() { - logger.info("AudioBanner: step backward command fired. State: \(playingState)") - stepBackward() - } - - // MARK: - Presenter Listener - - func onPlayTapped() { - logger.info("AudioBanner: play button tapped. State: \(playingState)") - playStartingCurrentPage() - } - - func onPauseResumeTapped() { - logger.info("AudioBanner: pause/resume button tapped. State: \(playingState)") - togglePlayPause() - } - - func onStopTapped() { - logger.info("AudioBanner: stop button tapped. State: \(playingState)") - stop() - } - - func onForwardTapped() { - logger.info("AudioBanner: step forward button tapped. State: \(playingState)") - stepForward() - } - - func onBackwardTapped() { - logger.info("AudioBanner: step backward button tapped. State: \(playingState)") - stepBackward() - } - - func cancelDownload() async { - logger.info("AudioBanner: cancel download tapped. State: \(playingState)") - await downloader.cancelAllAudioDownloads() - playbackEnded() + private var selectedReciter: Reciter? { + let storedSelectedReciterId = preferences.lastSelectedReciterId + let selectedReciter = reciters.first { $0.id == storedSelectedReciterId } + if selectedReciter == nil { + let firstReciter = reciters.first + logger.error("AudioBanner: couldn't find reciter \(storedSelectedReciterId) using \(String(describing: firstReciter?.id)) instead") + return firstReciter + } + return selectedReciter } - func showReciterView() { - logger.info("AudioBanner: show reciter view") + private func onPlayingStateStopped() { if let selectedReciter { crasher.setValue(selectedReciter.id, forKey: .reciterId) } @@ -237,22 +181,6 @@ public final class AudioBannerViewModel: RemoteCommandsHandlerDelegate { remoteCommandsHandler.startListeningToPlayCommand() } - // MARK: Private - - private let analytics: AnalyticsLibrary - private let reciterRetreiver: ReciterDataRetriever - private let recentRecitersService: RecentRecitersService - private let preferences = ReciterPreferences.shared - private let lastAyahFinder: LastAyahFinder = PreferencesLastAyahFinder.shared - private let audioPlayer: QuranAudioPlayer - private let downloader: QuranAudioDownloader - private let remoteCommandsHandler: RemoteCommandsHandler - - private var verseRuns: Runs = .one - private var listRuns: Runs = .one - private var reciters: [Reciter] = [] - private var cancellableTasks: Set = [] - @objc private func applicationDidBecomeActive() { // re-assign playingState to update UI @@ -417,13 +345,18 @@ public final class AudioBannerViewModel: RemoteCommandsHandlerDelegate { logger.info("AudioBanner: will start downloading") crasher.setValue(true, forKey: .downloadingQuran) playingState = .downloading(progress: 0) - internalActions?.willStartDownloading() + + guard let audioRange else { + return + } + let message = audioMessage("audio.downloading.message", audioRange: audioRange) + toast = (message, action: nil) } private func updateDownloadProgress() async { let downloads = await downloader.runningAudioDownloads() let progress = downloads.map(\.currentProgress.progress).reduce(0, +) - playingState = .downloading(progress: Float(progress) / Float(downloads.count)) + playingState = .downloading(progress: progress / Double(downloads.count)) } private func playingStarted() { @@ -432,16 +365,155 @@ public final class AudioBannerViewModel: RemoteCommandsHandlerDelegate { crasher.setValue(false, forKey: .downloadingQuran) remoteCommandsHandler.startListening() playingState = .playing - internalActions?.playingStarted() + + guard let audioRange else { + return + } + + let message = audioMessage("audio.playing.message", audioRange: audioRange) + toast = (message, action: ToastAction(title: l("audio.playing.action.modify")) { [weak self] in + self?.showAdvancedAudioOptions() + }) } private func playbackFailed(_ error: Error) { logger.info("AudioBanner: failed to playing audio. \(error)") - internalActions?.showError(error) + self.error = error + playbackEnded() + } + + private func audioMessage(_ format: String, audioRange: AudioRange) -> String { + lFormat(format, audioRange.start.localizedName, audioRange.end.localizedName) + } +} + +extension AudioBannerViewModel { + func playFromBanner() { + logger.info("AudioBanner: play button tapped. State: \(playingState)") + playStartingCurrentPage() + } + + func pauseFromBanner() { + logger.info("AudioBanner: pause button tapped. State: \(playingState)") + pause() + } + + func resumeFromBanner() { + logger.info("AudioBanner: resume button tapped. State: \(playingState)") + resume() + } + + func stopFromBanner() { + logger.info("AudioBanner: stop button tapped. State: \(playingState)") + stop() + } + + func forwardFromBanner() { + logger.info("AudioBanner: step forward button tapped. State: \(playingState)") + stepForward() + } + + func backwardFromBanner() { + logger.info("AudioBanner: step backward button tapped. State: \(playingState)") + stepBackward() + } + + func cancelDownload() async { + logger.info("AudioBanner: cancel download tapped. State: \(playingState)") + await downloader.cancelAllAudioDownloads() playbackEnded() } } +extension AudioBannerViewModel: RemoteCommandsHandlerDelegate { + func onPlayCommandFired() { + logger.info("AudioBanner: play command fired. State: \(playingState)") + switch playingState { + case .stopped: playStartingCurrentPage() + case .paused, .playing: resume() + case .downloading: break + } + } + + func onPauseCommandFired() { + logger.info("AudioBanner: pause command fired. State: \(playingState)") + pause() + } + + func onTogglePlayPauseCommandFired() { + logger.info("AudioBanner: toggle play/pause command fired. State: \(playingState)") + togglePlayPause() + } + + func onStepForwardCommandFired() { + logger.info("AudioBanner: step forward command fired. State: \(playingState)") + stepForward() + } + + func onStepBackwardCommandFire() { + logger.info("AudioBanner: step backward command fired. State: \(playingState)") + stepBackward() + } +} + +extension AudioBannerViewModel: ReciterListListener { + func presentReciterList() { + logger.info("AudioBanner: reciters button tapped. State: \(playingState)") + let viewController = reciterListBuilder.build(withListener: self) + viewControllerToPresent = ReciterNavigationController(rootViewController: viewController) + } + + public func onSelectedReciterChanged(to reciter: Reciter) { + logger.info("AudioBanner: onSelectedReciterChanged to \(reciter.id)") + selectReciter(reciter) + playingState = .stopped + } + + public func dismissReciterList() { + logger.info("AudioBanner: dismiss reciters list") + dismissPresentedViewController = true + } +} + +extension AudioBannerViewModel: AdvancedAudioOptionsListener { + private var advancedAudioOptions: AdvancedAudioOptions? { + guard let audioRange, let selectedReciter else { + return nil + } + return AdvancedAudioOptions( + reciter: selectedReciter, + start: audioRange.start, + end: audioRange.end, + verseRuns: verseRuns, + listRuns: listRuns + ) + } + + func showAdvancedAudioOptions() { + logger.info("AudioBanner: more button tapped. State: \(playingState)") + if case .stopped = playingState { + setAudioRangeForCurrentPage() + } + + guard let options = advancedAudioOptions else { + logger.info("AudioBanner: showAdvancedAudioOptions couldn't construct advanced audio options") + return + } + viewControllerToPresent = advancedAudioOptionsBuilder.build(withListener: self, options: options) + } + + public func updateAudioOptions(to newOptions: AdvancedAudioOptions) { + logger.info("AudioBanner: playing advanced audio options \(newOptions)") + selectReciter(newOptions.reciter) + play(from: newOptions.start, to: newOptions.end, verseRuns: newOptions.verseRuns, listRuns: newOptions.listRuns) + } + + public func dismissAudioOptions() { + logger.info("AudioBanner: dismiss advanced audio options") + dismissPresentedViewController = true + } +} + private extension AnalyticsLibrary { func playFrom(menu: Bool) { logEvent("PlayAudioFrom", value: menu ? "Menu" : "AudioBar") diff --git a/Features/AudioBannerFeature/AudioDownloadingBarView.swift b/Features/AudioBannerFeature/AudioDownloadingBarView.swift deleted file mode 100644 index b956f68c..00000000 --- a/Features/AudioBannerFeature/AudioDownloadingBarView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// AudioDownloadingBarView.swift -// Quran -// -// Created by Mohamed Afifi on 5/8/16. -// -// Quran for iOS is a Quran reading application for iOS. -// Copyright (C) 2017 Quran.com -// - -import Localization -import UIKit - -class AudioDownloadingBarView: UIView { - // MARK: Lifecycle - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - setUp() - } - - override init(frame: CGRect) { - super.init(frame: frame) - setUp() - } - - // MARK: Internal - - @IBOutlet var cancelButton: UIButton! - @IBOutlet var progressView: UIProgressView! - @IBOutlet var infoLabel: UILabel! - - // MARK: Private - - private func setUp() { - loadViewFrom(nibName: "AudioDownloadingBarView", bundle: .module) - infoLabel.text = lAndroid("downloading_title") - } -} diff --git a/Features/AudioBannerFeature/AudioDownloadingBarView.xib b/Features/AudioBannerFeature/AudioDownloadingBarView.xib deleted file mode 100644 index 71c59e90..00000000 --- a/Features/AudioBannerFeature/AudioDownloadingBarView.xib +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Features/AudioBannerFeature/AudioPlayBarView.swift b/Features/AudioBannerFeature/AudioPlayBarView.swift deleted file mode 100644 index 860d537f..00000000 --- a/Features/AudioBannerFeature/AudioPlayBarView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// AudioPlayBarView.swift -// Quran -// -// Created by Mohamed Afifi on 5/8/16. -// -// Quran for iOS is a Quran reading application for iOS. -// Copyright (C) 2017 Quran.com -// - -import UIKit - -class AudioPlayBarView: UIView { - // MARK: Lifecycle - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - setUp() - } - - override init(frame: CGRect) { - super.init(frame: frame) - setUp() - } - - // MARK: Internal - - @IBOutlet var stopButton: UIButton! - @IBOutlet var previousButton: UIButton! - @IBOutlet var pauseResumeButton: UIButton! - @IBOutlet var nextButton: UIButton! - @IBOutlet var moreButton: UIButton! - - // MARK: Private - - private func setUp() { - loadViewFrom(nibName: "AudioPlayBarView", bundle: .module) - } -} diff --git a/Features/AudioBannerFeature/AudioPlayBarView.xib b/Features/AudioBannerFeature/AudioPlayBarView.xib deleted file mode 100644 index 9f81d4d1..00000000 --- a/Features/AudioBannerFeature/AudioPlayBarView.xib +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Features/AudioBannerFeature/AudioReciterBarView.swift b/Features/AudioBannerFeature/AudioReciterBarView.swift deleted file mode 100644 index 0847141a..00000000 --- a/Features/AudioBannerFeature/AudioReciterBarView.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// AudioReciterBarView.swift -// Quran -// -// Created by Mohamed Afifi on 5/8/16. -// -// Quran for iOS is a Quran reading application for iOS. -// Copyright (C) 2017 Quran.com -// - -import UIKit -import UIx - -class AudioReciterBarView: UIView { - // MARK: Lifecycle - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - setUp() - } - - override init(frame: CGRect) { - super.init(frame: frame) - setUp() - } - - // MARK: Internal - - @IBOutlet var imageView: UIImageView! - @IBOutlet var titleLabel: UILabel! - @IBOutlet var playButton: UIButton! - @IBOutlet var moreButton: UIButton! - @IBOutlet var backgroundButton: BackgroundColorButton! - - // MARK: Private - - private func setUp() { - backgroundColor = .clear - loadViewFrom(nibName: "AudioReciterBarView", bundle: .module) - } -} diff --git a/Features/AudioBannerFeature/AudioReciterBarView.xib b/Features/AudioBannerFeature/AudioReciterBarView.xib deleted file mode 100644 index bc7e453e..00000000 --- a/Features/AudioBannerFeature/AudioReciterBarView.xib +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Package.swift b/Package.swift index f13ee168..cda443d3 100644 --- a/Package.swift +++ b/Package.swift @@ -527,6 +527,7 @@ private func featuresTargets() -> [[Target]] { target(type, name: "AudioBannerFeature", hasTests: false, dependencies: [ "Caching", "AppDependencies", + "NoorUI", "ReciterListFeature", "AdvancedAudioOptionsFeature", ]), diff --git a/UI/NoorUI/Features/AudioBanner/AudioBannerViewUI.swift b/UI/NoorUI/Features/AudioBanner/AudioBannerViewUI.swift new file mode 100644 index 00000000..ec02e712 --- /dev/null +++ b/UI/NoorUI/Features/AudioBanner/AudioBannerViewUI.swift @@ -0,0 +1,233 @@ +// +// AudioBannerViewUI.swift +// +// +// Created by Mohamed Afifi on 2024-09-02. +// + +import Localization +import SwiftUI +import UIx + +public enum AudioBannerState { + case playing(paused: Bool) + case readyToPlay(reciter: String) + case downloading(progress: Double) +} + +public struct AudioBannerActions { + let play: () -> Void + let pause: () -> Void + let resume: () -> Void + let stop: () -> Void + let backward: () -> Void + let forward: () -> Void + let cancelDownloading: AsyncAction + let reciters: () -> Void + let more: () -> Void + public init(play: @escaping () -> Void, pause: @escaping () -> Void, resume: @escaping () -> Void, stop: @escaping () -> Void, backward: @escaping () -> Void, forward: @escaping () -> Void, cancelDownloading: @escaping AsyncAction, reciters: @escaping () -> Void, more: @escaping () -> Void) { + self.play = play + self.pause = pause + self.resume = resume + self.stop = stop + self.backward = backward + self.forward = forward + self.cancelDownloading = cancelDownloading + self.reciters = reciters + self.more = more + } +} + +public struct AudioBannerViewUI: View { + private let state: AudioBannerState + private let actions: AudioBannerActions + public init(state: AudioBannerState, actions: AudioBannerActions) { + self.state = state + self.actions = actions + } + + public var body: some View { + ZStack { + switch state { + case .playing(let paused): + AudioPlaying(paused: paused, actions: actions) + case .readyToPlay(let reciter): + ReadyToPlay(reciter: reciter, actions: actions) + case .downloading(let progress): + Downloading(progress: progress, actions: actions) + } + } + .font(.title2) + .background( + BannerBackground(color: .clear) + .shadow(color: .label.opacity(0.33), radius: 2) + ) + } +} + +private struct AudioPlaying: View { + let paused: Bool + let actions: AudioBannerActions + + var body: some View { + HStack { + Button(action: actions.stop) { + NoorSystemImage.stop.image + .padding() + } + Spacer() + + Button(action: actions.backward) { + NoorSystemImage.backward.image + .padding() + } + Group { + if paused { + Button(action: actions.resume) { + NoorSystemImage.play.image + } + } else { + Button(action: actions.pause) { + NoorSystemImage.pause.image + } + } + } + .padding() + Button(action: actions.forward) { + NoorSystemImage.forward.image + .padding() + } + + Spacer() + Button(action: actions.more) { + NoorSystemImage.more.image + .padding() + } + } + } +} + +private struct ReadyToPlay: View { + let reciter: String + let actions: AudioBannerActions + var body: some View { + ZStack { + HStack { + Button(action: actions.play) { + NoorSystemImage.play.image + .padding() + } + Spacer() + Text(reciter) + .font(.body) + .lineLimit(1) + Spacer() + Button(action: actions.more) { + NoorSystemImage.more.image + .padding() + } + } + .background { + Button(action: actions.reciters) { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(CustomButtonStyle { config in + config.label + .background( + BannerBackground(color: config.isPressed ? .systemFill : Color.clear) + ) + }) + } + } + } +} + +private struct Downloading: View { + let progress: Double + let actions: AudioBannerActions + var body: some View { + HStack { + AsyncButton(action: actions.cancelDownloading) { + ZStack { + // workaround to have uniform height. + NoorSystemImage.more.image + .padding() + .hidden() + NoorSystemImage.cancel.image + .padding() + } + .overlay(Divider(), alignment: .trailing) + } + + Spacer() + VStack { + ProgressView(value: progress) + .progressViewStyle(LinearProgressViewStyle()) + + Text(lAndroid("downloading_title")) + .font(.body) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.trailing) + } + } +} + +private struct BannerBackground: View { + let color: Color + + var body: some View { + color + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 15.0)) + .ignoresSafeArea(edges: [.bottom, .leading, .trailing]) + } +} + +#Preview { + struct PreviewView: View { + let actions = AudioBannerActions( + play: {}, + pause: {}, + resume: {}, + stop: {}, + backward: {}, + forward: {}, + cancelDownloading: {}, + reciters: {}, + more: {} + ) + + let readyToPlay = AudioBannerState.readyToPlay(reciter: "Mishary Al-afasy") + let playing = AudioBannerState.playing(paused: false) + let downloading = AudioBannerState.downloading(progress: 0.7) + var state: AudioBannerState { + switch counter % 3 { + case 0: readyToPlay + case 1: playing + default: downloading + } + } + + @State var counter: Int = 0 + + var body: some View { + VStack { + Spacer() + Button { + counter += 1 + } label: { + Text("Rotate") + } + Spacer() + Group { + AudioBannerViewUI(state: state, actions: actions) + } + } + } + } + + return PreviewView() +} diff --git a/UI/NoorUI/Images/NoorSystemImage.swift b/UI/NoorUI/Images/NoorSystemImage.swift index 368ec7d5..8e6c227b 100644 --- a/UI/NoorUI/Images/NoorSystemImage.swift +++ b/UI/NoorUI/Images/NoorSystemImage.swift @@ -24,6 +24,13 @@ public enum NoorSystemImage: String { case search = "magnifyingglass" case mushafs = "books.vertical.fill" case debug = "ant" + case play = "play.fill" + case stop = "stop.fill" + case pause = "pause.fill" + case more = "ellipsis.circle" + case backward = "backward.fill" + case forward = "forward.fill" + case cancel = "xmark" // MARK: Public