diff --git a/Modules/Package.swift b/Modules/Package.swift index b489cb2d1223..9f80fa8b22c0 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -8,18 +8,18 @@ let package = Package( .iOS(.v16), ], products: XcodeSupport.products + [ - .library(name: "JetpackStatsWidgetsCore", targets: ["JetpackStatsWidgetsCore"]), + .library(name: "AsyncImageKit", targets: ["AsyncImageKit"]), .library(name: "DesignSystem", targets: ["DesignSystem"]), + .library(name: "JetpackStatsWidgetsCore", targets: ["JetpackStatsWidgetsCore"]), .library(name: "WordPressFlux", targets: ["WordPressFlux"]), - .library(name: "WordPressMedia", targets: ["WordPressMedia"]), .library(name: "WordPressShared", targets: ["WordPressShared"]), .library(name: "WordPressUI", targets: ["WordPressUI"]), ], dependencies: [ .package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"), .package(url: "https://github.com/Alamofire/Alamofire", from: "5.9.1"), - .package(url: "https://github.com/Alamofire/AlamofireImage", from: "4.3.0"), .package(url: "https://github.com/AliSoftware/OHHTTPStubs", from: "9.1.0"), + .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), .package(url: "https://github.com/Automattic/Automattic-Tracks-iOS", from: "3.4.2"), .package(url: "https://github.com/Automattic/AutomatticAbout-swift", from: "1.1.4"), .package(url: "https://github.com/Automattic/Gravatar-SDK-iOS", from: "3.1.1"), @@ -52,23 +52,34 @@ let package = Package( .package(url: "https://github.com/Automattic/color-studio", branch: "trunk"), ], targets: XcodeSupport.targets + [ - .target(name: "JetpackStatsWidgetsCore", swiftSettings: [.swiftLanguageMode(.v5)]), + .target(name: "AsyncImageKit", dependencies: [ + .product(name: "Collections", package: "swift-collections"), + .product(name: "Gifu", package: "Gifu"), + ]), .target(name: "DesignSystem", swiftSettings: [.swiftLanguageMode(.v5)]), + .target(name: "JetpackStatsWidgetsCore", swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "UITestsFoundation", dependencies: [ .product(name: "ScreenObject", package: "ScreenObject"), .product(name: "XCUITestHelpers", package: "XCUITestHelpers"), ], swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressFlux", swiftSettings: [.swiftLanguageMode(.v5)]), - .target(name: "WordPressMedia"), .target(name: "WordPressSharedObjC", resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressShared", dependencies: [.target(name: "WordPressSharedObjC")], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressTesting", resources: [.process("Resources")]), - .target(name: "WordPressUI", dependencies: [.target(name: "WordPressShared")], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]), + .target( + name: "WordPressUI", + dependencies: [ + "AsyncImageKit", + .target(name: "WordPressShared") + ], + resources: [.process("Resources")], + swiftSettings: [.swiftLanguageMode(.v5)] + ), .testTarget(name: "JetpackStatsWidgetsCoreTests", dependencies: [.target(name: "JetpackStatsWidgetsCore")], swiftSettings: [.swiftLanguageMode(.v5)]), .testTarget(name: "DesignSystemTests", dependencies: [.target(name: "DesignSystem")], swiftSettings: [.swiftLanguageMode(.v5)]), .testTarget(name: "WordPressFluxTests", dependencies: ["WordPressFlux"], swiftSettings: [.swiftLanguageMode(.v5)]), - .testTarget(name: "WordPressMediaTests", dependencies: [ - .target(name: "WordPressMedia"), + .testTarget(name: "AsyncImageKitTests", dependencies: [ + .target(name: "AsyncImageKit"), .target(name: "WordPressTesting"), .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs") ]), @@ -143,10 +154,9 @@ enum XcodeSupport { "JetpackStatsWidgetsCore", "WordPressFlux", "WordPressShared", - "WordPressMedia", + "AsyncImageKit", "WordPressUI", .product(name: "Alamofire", package: "Alamofire"), - .product(name: "AlamofireImage", package: "AlamofireImage"), .product(name: "AutomatticAbout", package: "AutomatticAbout-swift"), .product(name: "AutomatticTracks", package: "Automattic-Tracks-iOS"), .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), @@ -191,6 +201,7 @@ enum XcodeSupport { .xcodeTarget("XcodeTarget_StatsWidget", dependencies: [ "JetpackStatsWidgetsCore", "WordPressShared", + "WordPressUI", .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), .product(name: "WordPressAPI", package: "wordpress-rs"), .product(name: "ColorStudio", package: "color-studio"), diff --git a/Modules/Sources/WordPressMedia/AnimagedImage.swift b/Modules/Sources/AsyncImageKit/Helpers/AnimagedImage.swift similarity index 100% rename from Modules/Sources/WordPressMedia/AnimagedImage.swift rename to Modules/Sources/AsyncImageKit/Helpers/AnimagedImage.swift diff --git a/Modules/Sources/WordPressMedia/FaviconService.swift b/Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift similarity index 100% rename from Modules/Sources/WordPressMedia/FaviconService.swift rename to Modules/Sources/AsyncImageKit/Helpers/FaviconService.swift diff --git a/Modules/Sources/WordPressMedia/ImageDecoder.swift b/Modules/Sources/AsyncImageKit/Helpers/ImageDecoder.swift similarity index 99% rename from Modules/Sources/WordPressMedia/ImageDecoder.swift rename to Modules/Sources/AsyncImageKit/Helpers/ImageDecoder.swift index 899bf7fead2a..8e7e8c0b44ef 100644 --- a/Modules/Sources/WordPressMedia/ImageDecoder.swift +++ b/Modules/Sources/AsyncImageKit/Helpers/ImageDecoder.swift @@ -70,7 +70,7 @@ private extension Data { } } -private extension CGSize { +extension CGSize { func scaled(by scale: CGFloat) -> CGSize { CGSize(width: width * scale, height: height * scale) } diff --git a/Modules/Sources/WordPressMedia/MemoryCache.swift b/Modules/Sources/AsyncImageKit/Helpers/MemoryCache.swift similarity index 100% rename from Modules/Sources/WordPressMedia/MemoryCache.swift rename to Modules/Sources/AsyncImageKit/Helpers/MemoryCache.swift diff --git a/Modules/Sources/WordPressMedia/ImageDownloader.swift b/Modules/Sources/AsyncImageKit/ImageDownloader.swift similarity index 84% rename from Modules/Sources/WordPressMedia/ImageDownloader.swift rename to Modules/Sources/AsyncImageKit/ImageDownloader.swift index 08e6b907bd43..d634bd537c54 100644 --- a/Modules/Sources/WordPressMedia/ImageDownloader.swift +++ b/Modules/Sources/AsyncImageKit/ImageDownloader.swift @@ -3,8 +3,9 @@ import UIKit /// The system that downloads and caches images, and prepares them for display. @ImageDownloaderActor public final class ImageDownloader { + public nonisolated static let shared = ImageDownloader() + private nonisolated let cache: MemoryCacheProtocol - private let authenticator: MediaRequestAuthenticatorProtocol? private let urlSession = URLSession { $0.urlCache = nil @@ -21,14 +22,12 @@ public final class ImageDownloader { private var tasks: [String: ImageDataTask] = [:] public nonisolated init( - cache: MemoryCacheProtocol = MemoryCache.shared, - authenticator: MediaRequestAuthenticatorProtocol? + cache: MemoryCacheProtocol = MemoryCache.shared ) { self.cache = cache - self.authenticator = authenticator } - public func image(from url: URL, host: MediaHost? = nil, options: ImageRequestOptions = .init()) async throws -> UIImage { + public func image(from url: URL, host: MediaHostProtocol? = nil, options: ImageRequestOptions = .init()) async throws -> UIImage { try await image(for: ImageRequest(url: url, host: host, options: options)) } @@ -39,7 +38,7 @@ public final class ImageDownloader { return image } let data = try await data(for: request) - let image = try await ImageDecoder.makeImage(from: data, size: options.size) + let image = try await ImageDecoder.makeImage(from: data, size: options.size.map(CGSize.init)) if options.isMemoryCacheEnabled { cache[key] = image } @@ -55,8 +54,8 @@ public final class ImageDownloader { switch request.source { case .url(let url, let host): var request: URLRequest - if let host, let authenticator { - request = try await authenticator.authenticatedRequest(for: url, host: host) + if let host { + request = try await host.authenticatedRequest(for: url) } else { request = URLRequest(url: url) } @@ -69,24 +68,30 @@ public final class ImageDownloader { // MARK: - Caching + /// Returns an image from the memory cache. + nonisolated public func cachedImage(for request: ImageRequest) -> UIImage? { + guard let imageURL = request.source.url else { return nil } + return cachedImage(for: imageURL, size: request.options.size) + } + /// Returns an image from the memory cache. /// /// - note: Use it to retrieve the image synchronously, which is no not possible /// with the async functions. - nonisolated public func cachedImage(for imageURL: URL, size: CGSize? = nil) -> UIImage? { + nonisolated public func cachedImage(for imageURL: URL, size: ImageSize? = nil) -> UIImage? { cache[makeKey(for: imageURL, size: size)] } - nonisolated public func setCachedImage(_ image: UIImage?, for imageURL: URL, size: CGSize? = nil) { + nonisolated public func setCachedImage(_ image: UIImage?, for imageURL: URL, size: ImageSize? = nil) { cache[makeKey(for: imageURL, size: size)] = image } - private nonisolated func makeKey(for imageURL: URL?, size: CGSize?) -> String { + private nonisolated func makeKey(for imageURL: URL?, size: ImageSize?) -> String { guard let imageURL else { assertionFailure("The request.url was nil") // This should never happen return "" } - return imageURL.absoluteString + (size.map { "?size=\($0)" } ?? "") + return imageURL.absoluteString + (size.map { "?w=\($0.width),h=\($0.height)" } ?? "") } public func clearURLSessionCache() { @@ -189,6 +194,6 @@ private extension URLSession { } } -public protocol MediaRequestAuthenticatorProtocol: Sendable { - @MainActor func authenticatedRequest(for url: URL, host: MediaHost) async throws -> URLRequest +public protocol MediaHostProtocol: Sendable { + @MainActor func authenticatedRequest(for url: URL) async throws -> URLRequest } diff --git a/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift b/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift new file mode 100644 index 000000000000..623d60ac2aa2 --- /dev/null +++ b/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift @@ -0,0 +1,111 @@ +import UIKit +import Collections + +@ImageDownloaderActor +public final class ImagePrefetcher { + private let downloader: ImageDownloader + private let maxConcurrentTasks: Int + private var queue = OrderedDictionary() + private var numberOfActiveTasks = 0 + + deinit { + let tasks = queue.values.compactMap(\.task) + for task in tasks { + task.cancel() + } + } + + public nonisolated init( + downloader: ImageDownloader = .shared, + maxConcurrentTasks: Int = 2 + ) { + self.downloader = downloader + self.maxConcurrentTasks = maxConcurrentTasks + } + + public nonisolated func startPrefetching(for requests: [ImageRequest]) { + Task { @ImageDownloaderActor in + for request in requests { + startPrefetching(for: request) + } + performPendingTasks() + } + } + + private func startPrefetching(for request: ImageRequest) { + let key = PrefetchKey(request: request) + guard queue[key] == nil else { + return + } + queue[key] = PrefetchTask() + } + + private func performPendingTasks() { + var index = 0 + func nextPendingTask() -> (PrefetchKey, PrefetchTask)? { + while index < queue.count { + if queue.elements[index].value.task == nil { + return queue.elements[index] + } + index += 1 + } + return nil + } + while numberOfActiveTasks < maxConcurrentTasks, let (key, task) = nextPendingTask() { + task.task = Task { + await self.actuallyPrefetchImage(for: key.request) + } + numberOfActiveTasks += 1 + } + } + + private func actuallyPrefetchImage(for request: ImageRequest) async { + _ = try? await downloader.image(for: request) + + numberOfActiveTasks -= 1 + queue[PrefetchKey(request: request)] = nil + performPendingTasks() + } + + public nonisolated func stopPrefetching(for requests: [ImageRequest]) { + Task { @ImageDownloaderActor in + for request in requests { + stopPrefetching(for: request) + } + performPendingTasks() + } + } + + private func stopPrefetching(for request: ImageRequest) { + let key = PrefetchKey(request: request) + if let task = queue.removeValue(forKey: key) { + task.task?.cancel() + } + } + + public nonisolated func stopAll() { + Task { @ImageDownloaderActor in + for (_, value) in queue { + value.task?.cancel() + } + queue.removeAll() + } + } + + private struct PrefetchKey: Hashable, Sendable { + let request: ImageRequest + + func hash(into hasher: inout Hasher) { + request.source.url?.hash(into: &hasher) + } + + static func == (lhs: PrefetchKey, rhs: PrefetchKey) -> Bool { + let (lhs, rhs) = (lhs.request, rhs.request) + return (lhs.source.url, lhs.options) == (rhs.source.url, rhs.options) + } + } + + private final class PrefetchTask: @unchecked Sendable { + var task: Task? + } +} diff --git a/Modules/Sources/AsyncImageKit/ImageRequest.swift b/Modules/Sources/AsyncImageKit/ImageRequest.swift new file mode 100644 index 000000000000..5a4ada4df736 --- /dev/null +++ b/Modules/Sources/AsyncImageKit/ImageRequest.swift @@ -0,0 +1,84 @@ +import UIKit + +public final class ImageRequest: Sendable { + public enum Source: Sendable { + case url(URL, MediaHostProtocol?) + case urlRequest(URLRequest) + + var url: URL? { + switch self { + case .url(let url, _): url + case .urlRequest(let request): request.url + } + } + } + + let source: Source + let options: ImageRequestOptions + + public init(url: URL, host: MediaHostProtocol? = nil, options: ImageRequestOptions = .init()) { + self.source = .url(url, host) + self.options = options + } + + public init(urlRequest: URLRequest, options: ImageRequestOptions = .init()) { + self.source = .urlRequest(urlRequest) + self.options = options + } +} + +public struct ImageRequestOptions: Hashable, Sendable { + /// Resize the thumbnail to the given size. By default, `nil`. + public var size: ImageSize? + + /// If enabled, uses ``MemoryCache`` for caching decompressed images. + public var isMemoryCacheEnabled = true + + /// If enabled, uses `URLSession` preconfigured with a custom `URLCache` + /// with a relatively high disk capacity. By default, `true`. + public var isDiskCacheEnabled = true + + public init( + size: ImageSize? = nil, + isMemoryCacheEnabled: Bool = true, + isDiskCacheEnabled: Bool = true + ) { + self.size = size + self.isMemoryCacheEnabled = isMemoryCacheEnabled + self.isDiskCacheEnabled = isDiskCacheEnabled + } +} + +/// Image size in **pixels**. +public struct ImageSize: Hashable, Sendable { + public let width: CGFloat + public let height: CGFloat + + public init(width: CGFloat, height: CGFloat) { + self.width = width + self.height = height + } + + public init(_ size: CGSize) { + self.width = size.width + self.height = size.height + } + + /// Initializes `ImageSize` with the given size scaled for the given view. + @MainActor + public init(scaling size: CGSize, in view: UIView) { + self.init(size.scaled(by: view.traitCollection.displayScale)) + } + + /// Initializes `ImageSize` with the given size scaled for the current trait + /// collection display scale. + public init(scaling size: CGSize) { + self.init(size.scaled(by: UITraitCollection.current.displayScale)) + } +} + +extension CGSize { + init(_ size: ImageSize) { + self.init(width: size.width, height: size.height) + } +} diff --git a/WordPress/Classes/Utility/Media/AsyncImageView.swift b/Modules/Sources/AsyncImageKit/Views/AsyncImageView.swift similarity index 62% rename from WordPress/Classes/Utility/Media/AsyncImageView.swift rename to Modules/Sources/AsyncImageKit/Views/AsyncImageView.swift index 307e3ae6e94e..464490a0bfb2 100644 --- a/WordPress/Classes/Utility/Media/AsyncImageView.swift +++ b/Modules/Sources/AsyncImageKit/Views/AsyncImageView.swift @@ -1,44 +1,47 @@ import UIKit import Gifu -import WordPressMedia /// A simple image view that supports rendering both static and animated images /// (see ``AnimatedImage``). @MainActor -final class AsyncImageView: UIView { +public final class AsyncImageView: UIView { private let imageView = GIFImageView() private var errorView: UIImageView? private var spinner: UIActivityIndicatorView? - private let controller = ImageViewController() + private let controller = ImageLoadingController() - enum LoadingStyle { + public enum LoadingStyle { /// Shows a secondary background color during the download. case background /// Shows a spinner during the download. case spinner } - struct Configuration { + public struct Configuration { /// Image tint color. - var tintColor: UIColor? + public var tintColor: UIColor? /// Image view content mode. - var contentMode: UIView.ContentMode? + public var contentMode: UIView.ContentMode? /// Enabled by default and shows an error icon on failures. - var isErrorViewEnabled = true + public var isErrorViewEnabled = true /// By default, `background`. - var loadingStyle = LoadingStyle.background + public var loadingStyle = LoadingStyle.background + + public var passTouchesToSuperview = false + + public init() {} } - var configuration = Configuration() { + public var configuration = Configuration() { didSet { didUpdateConfiguration(configuration) } } /// The currently displayed image. If the image is animated, returns an /// instance of ``AnimatedImage``. - var image: UIImage? { + public var image: UIImage? { didSet { if let image { imageView.configure(image: image) @@ -48,14 +51,14 @@ final class AsyncImageView: UIView { } } - override init(frame: CGRect) { + public override init(frame: CGRect) { super.init(frame: frame) + setupView() } - required init?(coder: NSCoder) { - super.init(coder: coder) - setupView() + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } private func setupView() { @@ -63,7 +66,12 @@ final class AsyncImageView: UIView { addSubview(imageView) imageView.translatesAutoresizingMaskIntoConstraints = false - pinSubviewToAllEdges(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + ]) imageView.clipsToBounds = true imageView.contentMode = .scaleAspectFill @@ -73,22 +81,26 @@ final class AsyncImageView: UIView { } /// Removes the current image and stops the outstanding downloads. - func prepareForReuse() { + public func prepareForReuse() { controller.prepareForReuse() image = nil } /// - parameter size: Target image size in pixels. - func setImage( + public func setImage( with imageURL: URL, - host: MediaHost? = nil, - size: CGSize? = nil, - completion: (@MainActor (Result) -> Void)? = nil + host: MediaHostProtocol? = nil, + size: ImageSize? = nil ) { - controller.setImage(with: imageURL, host: host, size: size, completion: completion) + let request = ImageRequest(url: imageURL, host: host, options: ImageRequestOptions(size: size)) + controller.setImage(with: request) } - private func setState(_ state: ImageViewController.State) { + public func setImage(with request: ImageRequest, completion: (@MainActor (Result) -> Void)? = nil) { + controller.setImage(with: request, completion: completion) + } + + private func setState(_ state: ImageLoadingController.State) { imageView.isHidden = true errorView?.isHidden = true spinner?.stopAnimating() @@ -128,7 +140,10 @@ final class AsyncImageView: UIView { let spinner = UIActivityIndicatorView() addSubview(spinner) spinner.translatesAutoresizingMaskIntoConstraints = false - pinSubviewAtCenter(spinner) + NSLayoutConstraint.activate([ + spinner.centerXAnchor.constraint(equalTo: centerXAnchor), + spinner.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) self.spinner = spinner return spinner } @@ -141,16 +156,27 @@ final class AsyncImageView: UIView { errorView.tintColor = .separator addSubview(errorView) errorView.translatesAutoresizingMaskIntoConstraints = false - pinSubviewAtCenter(errorView) + NSLayoutConstraint.activate([ + errorView.centerXAnchor.constraint(equalTo: centerXAnchor), + errorView.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) self.errorView = errorView return errorView } + + public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if configuration.passTouchesToSuperview && self.bounds.contains(point) { + // Pass the touch to the superview + return nil + } + return super.hitTest(point, with: event) + } } extension GIFImageView { /// If the image is an instance of `AnimatedImage` type, plays it as an /// animated image. - func configure(image: UIImage) { + public func configure(image: UIImage) { if let gif = image as? AnimatedImage, let data = gif.gifData { self.animate(withGIFData: data) } else { @@ -158,7 +184,7 @@ extension GIFImageView { } } - private func prepareForReuse() { + public func prepareForReuse() { if isAnimatingGIF { prepareForReuse() } else { diff --git a/WordPress/Classes/Utility/Media/CachedAsyncImage.swift b/Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift similarity index 78% rename from WordPress/Classes/Utility/Media/CachedAsyncImage.swift rename to Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift index a93fa0c2b663..d6ef77690540 100644 --- a/WordPress/Classes/Utility/Media/CachedAsyncImage.swift +++ b/Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift @@ -1,15 +1,13 @@ import SwiftUI -import DesignSystem -import WordPressMedia /// Asynchronous Image View that replicates the public API of `SwiftUI.AsyncImage`. /// It uses `ImageDownloader` to fetch and cache the images. -struct CachedAsyncImage: View where Content: View { +public struct CachedAsyncImage: View where Content: View { @State private var phase: AsyncImagePhase = .empty private let url: URL? private let content: (AsyncImagePhase) -> Content private let imageDownloader: ImageDownloader - private let host: MediaHost? + private let host: MediaHostProtocol? public var body: some View { content(phase) @@ -20,19 +18,24 @@ struct CachedAsyncImage: View where Content: View { /// Initializes an image without any customization. /// Provides a plain color as placeholder - init(url: URL?) where Content == _ConditionalContent { + public init(url: URL?) where Content == _ConditionalContent { self.init(url: url) { phase in if let image = phase.image { image } else { - Color(uiColor: UIAppColor.gray(.shade40)) + Color(uiColor: .secondarySystemBackground) } } } /// Allows content customization and providing a placeholder that will be shown /// until the image download is finalized. - init(url: URL?, host: MediaHost? = nil, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I: View, P: View { + public init( + url: URL?, + host: MediaHostProtocol? = nil, + @ViewBuilder content: @escaping (Image) -> I, + @ViewBuilder placeholder: @escaping () -> P + ) where Content == _ConditionalContent, I: View, P: View { self.init(url: url, host: host) { phase in if let image = phase.image { content(image) @@ -42,9 +45,9 @@ struct CachedAsyncImage: View where Content: View { } } - init( + public init( url: URL?, - host: MediaHost? = nil, + host: MediaHostProtocol? = nil, imageDownloader: ImageDownloader = .shared, @ViewBuilder content: @escaping (AsyncImagePhase) -> Content ) { diff --git a/WordPress/Classes/Utility/Media/ImageViewController.swift b/Modules/Sources/AsyncImageKit/Views/ImageLoadingController.swift similarity index 56% rename from WordPress/Classes/Utility/Media/ImageViewController.swift rename to Modules/Sources/AsyncImageKit/Views/ImageLoadingController.swift index 053e6018b072..064dfae9bd48 100644 --- a/WordPress/Classes/Utility/Media/ImageViewController.swift +++ b/Modules/Sources/AsyncImageKit/Views/ImageLoadingController.swift @@ -1,16 +1,14 @@ -import Foundation import UIKit -import WordPressMedia /// A convenience class for managing image downloads for individual views. @MainActor -final class ImageViewController { - var downloader: ImageDownloader = .shared - var onStateChanged: (State) -> Void = { _ in } +public final class ImageLoadingController { + public var downloader: ImageDownloader = .shared + public var onStateChanged: (State) -> Void = { _ in } - private(set) var task: Task? + public private(set) var task: Task? - enum State { + public enum State { case loading case success(UIImage) case failure(Error) @@ -20,34 +18,25 @@ final class ImageViewController { task?.cancel() } - func prepareForReuse() { + public init() {} + + public func prepareForReuse() { task?.cancel() task = nil } /// - parameter completion: Gets called on completion _after_ `onStateChanged`. - func setImage( - with imageURL: URL, - host: MediaHost? = nil, - size: CGSize? = nil, - completion: (@MainActor (Result) -> Void)? = nil - ) { + public func setImage(with request: ImageRequest, completion: (@MainActor (Result) -> Void)? = nil) { task?.cancel() - if let image = downloader.cachedImage(for: imageURL, size: size) { + if let image = downloader.cachedImage(for: request) { onStateChanged(.success(image)) completion?(.success(image)) } else { onStateChanged(.loading) task = Task { @MainActor [downloader, weak self] in do { - let options = ImageRequestOptions(size: size) - let image: UIImage - if let host { - image = try await downloader.image(from: imageURL, host: host, options: options) - } else { - image = try await downloader.image(from: imageURL, options: options) - } + let image = try await downloader.image(for: request) // This line guarantees that if you cancel on the main thread, // none of the `onStateChanged` callbacks get called. guard !Task.isCancelled else { return } diff --git a/WordPress/Classes/Utility/Media/UIImageView+ImageDownloader.swift b/Modules/Sources/AsyncImageKit/Views/UIImageView+ImageDownloader.swift similarity index 58% rename from WordPress/Classes/Utility/Media/UIImageView+ImageDownloader.swift rename to Modules/Sources/AsyncImageKit/Views/UIImageView+ImageDownloader.swift index 303c9a9f60f4..422a61af70d5 100644 --- a/WordPress/Classes/Utility/Media/UIImageView+ImageDownloader.swift +++ b/Modules/Sources/AsyncImageKit/Views/UIImageView+ImageDownloader.swift @@ -1,18 +1,16 @@ -import Foundation import UIKit import Gifu -import WordPressMedia extension UIImageView { @MainActor - var wp: ImageViewExtensions { ImageViewExtensions(imageView: self) } + public var wp: ImageViewExtensions { ImageViewExtensions(imageView: self) } } @MainActor -struct ImageViewExtensions { +public struct ImageViewExtensions { var imageView: UIImageView - func prepareForReuse() { + public func prepareForReuse() { controller.prepareForReuse() if let gifView = imageView as? GIFImageView, gifView.isAnimatingGIF { @@ -22,20 +20,19 @@ struct ImageViewExtensions { } } - func setImage( - with imageURL: URL, - host: MediaHost? = nil, - size: CGSize? = nil, - completion: (@MainActor (Result) -> Void)? = nil - ) { - controller.setImage(with: imageURL, host: host, size: size, completion: completion) + public func setImage(with imageURL: URL, host: MediaHostProtocol? = nil, size: ImageSize? = nil) { + setImage(with: ImageRequest(url: imageURL, host: host, options: ImageRequestOptions(size: size))) } - var controller: ImageViewController { - if let controller = objc_getAssociatedObject(imageView, ImageViewExtensions.controllerKey) as? ImageViewController { + public func setImage(with request: ImageRequest, completion: (@MainActor (Result) -> Void)? = nil) { + controller.setImage(with: request, completion: completion) + } + + public var controller: ImageLoadingController { + if let controller = objc_getAssociatedObject(imageView, ImageViewExtensions.controllerKey) as? ImageLoadingController { return controller } - let controller = ImageViewController() + let controller = ImageLoadingController() controller.onStateChanged = { [weak imageView] in guard let imageView else { return } setState($0, for: imageView) @@ -44,7 +41,7 @@ struct ImageViewExtensions { return controller } - private func setState(_ state: ImageViewController.State, for imageView: UIImageView) { + private func setState(_ state: ImageLoadingController.State, for imageView: UIImageView) { switch state { case .loading: break diff --git a/Modules/Sources/DesignSystem/Foundation/CGFloat+DesignSystem.swift b/Modules/Sources/DesignSystem/Foundation/CGFloat+DesignSystem.swift index b3ae3590a5e6..15a2b96b68d7 100644 --- a/Modules/Sources/DesignSystem/Foundation/CGFloat+DesignSystem.swift +++ b/Modules/Sources/DesignSystem/Foundation/CGFloat+DesignSystem.swift @@ -12,10 +12,6 @@ public extension CGFloat { public static let max: CGFloat = 48 } - public enum Hitbox { - public static let minTappableLength: CGFloat = 44 - } - public enum Radius { public static let small: CGFloat = 5 public static let medium: CGFloat = 10 diff --git a/Modules/Sources/DesignSystem/Gallery/LengthGallery.swift b/Modules/Sources/DesignSystem/Gallery/LengthGallery.swift index 5e7749a55446..783a1452cffe 100644 --- a/Modules/Sources/DesignSystem/Gallery/LengthGallery.swift +++ b/Modules/Sources/DesignSystem/Gallery/LengthGallery.swift @@ -30,7 +30,7 @@ struct LengthGallery: View { ZStack { RoundedRectangle(cornerRadius: .DS.Radius.small) .fill(.background) - .frame(height: .DS.Hitbox.minTappableLength) + .frame(height: 44) HStack { Text(name) .offset(x: .DS.Padding.double) diff --git a/Modules/Sources/UITestsFoundation/Screens/Editor/EditorPostSettings.swift b/Modules/Sources/UITestsFoundation/Screens/Editor/EditorPostSettings.swift index e96795157d64..161a58d3bb12 100644 --- a/Modules/Sources/UITestsFoundation/Screens/Editor/EditorPostSettings.swift +++ b/Modules/Sources/UITestsFoundation/Screens/Editor/EditorPostSettings.swift @@ -11,22 +11,10 @@ public class EditorPostSettings: ScreenObject { $0.cells["Categories"] } - private let chooseFromMediaButtonGetter: (XCUIApplication) -> XCUIElement = { - $0.buttons["Choose from Media"] - } - private let tagsSectionGetter: (XCUIApplication) -> XCUIElement = { $0.cells["Tags"] } - private let featuredImageButtonGetter: (XCUIApplication) -> XCUIElement = { - $0.cells["SetFeaturedImage"] - } - - private let currentFeaturedImageGetter: (XCUIApplication) -> XCUIElement = { - $0.cells["CurrentFeaturedImage"] - } - private let publishDateButtonGetter: (XCUIApplication) -> XCUIElement = { $0.staticTexts["Publish Date"] } @@ -56,11 +44,11 @@ public class EditorPostSettings: ScreenObject { } var categoriesSection: XCUIElement { categoriesSectionGetter(app) } - var chooseFromMediaButton: XCUIElement { chooseFromMediaButtonGetter(app) } - var currentFeaturedImage: XCUIElement { currentFeaturedImageGetter(app) } + var chooseFromMediaButton: XCUIElement { app.buttons["Choose from Media"].firstMatch } var closeButton: XCUIElement { closeButtonGetter(app) } var backButton: XCUIElement? { backButtonGetter(app) } - var featuredImageButton: XCUIElement { featuredImageButtonGetter(app) } + var featuredImageCell: XCUIElement { app.cells["post_settings_featured_image_cell"].firstMatch } + var selectedFeaturedImage: XCUIElement { app.otherElements["featured_image_current_image"].firstMatch } var firstCalendarDayButton: XCUIElement { firstCalendarDayButtonGetter(app) } var monthLabel: XCUIElement { monthLabelGetter(app) } var nextMonthButton: XCUIElement { nextMonthButtonGetter(app) } @@ -100,16 +88,13 @@ public class EditorPostSettings: ScreenObject { } public func removeFeatureImage() throws -> EditorPostSettings { - currentFeaturedImage.tap() - - try FeaturedImageScreen() - .tapRemoveFeaturedImageButton() - + featuredImageCell.tap() + app.buttons["featured_image_button_remove"].firstMatch.tap() return try EditorPostSettings() } public func setFeaturedImage() throws -> EditorPostSettings { - featuredImageButton.tap() + featuredImageCell.tap() chooseFromMediaButton.tap() try MediaPickerAlbumScreen() .selectImage(atIndex: 0) // Select latest uploaded image @@ -125,9 +110,9 @@ public class EditorPostSettings: ScreenObject { XCTAssertTrue(tagsSection.staticTexts[postTag].exists, "Tag \(postTag) not set") } if hasImage { - XCTAssertTrue(currentFeaturedImage.exists, "Featured image not set") + XCTAssertTrue(selectedFeaturedImage.exists, "Featured image not set") } else { - XCTAssertFalse(currentFeaturedImage.exists, "Featured image is set but should not be") + XCTAssertFalse(selectedFeaturedImage.exists, "Featured image is set but should not be") } return try EditorPostSettings() diff --git a/Modules/Sources/UITestsFoundation/Screens/Editor/FeaturedImageScreen.swift b/Modules/Sources/UITestsFoundation/Screens/Editor/FeaturedImageScreen.swift deleted file mode 100644 index ded79b3ef42e..000000000000 --- a/Modules/Sources/UITestsFoundation/Screens/Editor/FeaturedImageScreen.swift +++ /dev/null @@ -1,29 +0,0 @@ -import ScreenObject -import XCTest - -public class FeaturedImageScreen: ScreenObject { - - private let removeFeaturedImageButtonGetter: (XCUIApplication) -> XCUIElement = { - $0.navigationBars.buttons["Remove Featured Image"] - } - - private let removeButtonGetter: (XCUIApplication) -> XCUIElement = { - $0.buttons["Remove"] - } - - var removeButton: XCUIElement { removeButtonGetter(app) } - var removeFeaturedImageButton: XCUIElement { removeFeaturedImageButtonGetter(app) } - - init(app: XCUIApplication = XCUIApplication()) throws { - try super.init( - expectedElementGetters: [ removeFeaturedImageButtonGetter ], - app: app - ) - } - - public func tapRemoveFeaturedImageButton() { - removeFeaturedImageButton.tap() - removeButton.tap() - } - -} diff --git a/Modules/Sources/WordPressMedia/ImageRequest.swift b/Modules/Sources/WordPressMedia/ImageRequest.swift deleted file mode 100644 index 3c77b28fe0cb..000000000000 --- a/Modules/Sources/WordPressMedia/ImageRequest.swift +++ /dev/null @@ -1,50 +0,0 @@ -import UIKit - -public final class ImageRequest: Sendable { - public enum Source: Sendable { - case url(URL, MediaHost?) - case urlRequest(URLRequest) - - var url: URL? { - switch self { - case .url(let url, _): url - case .urlRequest(let request): request.url - } - } - } - - let source: Source - let options: ImageRequestOptions - - public init(url: URL, host: MediaHost? = nil, options: ImageRequestOptions = .init()) { - self.source = .url(url, host) - self.options = options - } - - public init(urlRequest: URLRequest, options: ImageRequestOptions = .init()) { - self.source = .urlRequest(urlRequest) - self.options = options - } -} - -public struct ImageRequestOptions: Sendable { - /// Resize the thumbnail to the given size (in pixels). By default, `nil`. - public var size: CGSize? - - /// If enabled, uses ``MemoryCache`` for caching decompressed images. - public var isMemoryCacheEnabled = true - - /// If enabled, uses `URLSession` preconfigured with a custom `URLCache` - /// with a relatively high disk capacity. By default, `true`. - public var isDiskCacheEnabled = true - - public init( - size: CGSize? = nil, - isMemoryCacheEnabled: Bool = true, - isDiskCacheEnabled: Bool = true - ) { - self.size = size - self.isMemoryCacheEnabled = isMemoryCacheEnabled - self.isDiskCacheEnabled = isDiskCacheEnabled - } -} diff --git a/Modules/Sources/WordPressUI/BottomSheet/BottomSheetViewController.swift b/Modules/Sources/WordPressUI/BottomSheet/BottomSheetViewController.swift deleted file mode 100644 index 4d5d1a000a70..000000000000 --- a/Modules/Sources/WordPressUI/BottomSheet/BottomSheetViewController.swift +++ /dev/null @@ -1,276 +0,0 @@ -import UIKit - -public class BottomSheetViewController: UIViewController { - public enum Constants { - static let gripHeight: CGFloat = 5 - static let cornerRadius: CGFloat = 8 - static let buttonSpacing: CGFloat = 8 - static let minimumWidth: CGFloat = 300 - - /// The height of the space above the bottom sheet content, including the grip view and space around it. - /// - public static let additionalContentTopMargin: CGFloat = BottomSheetViewController.Constants.gripHeight - + BottomSheetViewController.Constants.Header.spacing - + BottomSheetViewController.Constants.Stack.insets.top - - enum Header { - static let spacing: CGFloat = 16 - static let insets: UIEdgeInsets = UIEdgeInsets(top: 0, left: 18, bottom: 0, right: 18) - } - - enum Button { - static let height: CGFloat = 54 - static let contentInsets: UIEdgeInsets = UIEdgeInsets(top: 0, left: 18, bottom: 0, right: 35) - static let titleInsets: UIEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 0) - } - - enum Stack { - static let insets: UIEdgeInsets = UIEdgeInsets(top: 5, left: 0, bottom: 0, right: 0) - } - } - - private var customHeaderSpacing: CGFloat? - - public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return childViewController?.supportedInterfaceOrientations ?? super.supportedInterfaceOrientations - } - - /// Additional safe are insets for regular horizontal size class - public var additionalSafeAreaInsetsRegular: UIEdgeInsets = .zero - - private weak var childViewController: DrawerPresentableViewController? - - public init(childViewController: DrawerPresentableViewController, - customHeaderSpacing: CGFloat? = nil) { - self.childViewController = childViewController - self.customHeaderSpacing = customHeaderSpacing - super.init(nibName: nil, bundle: nil) - } - - /// Presents the bottom sheet given an optional anchor and arrow directions for the popover on iPad. - /// If no anchors are provided, on iPad it will present a form sheet. - /// - Parameters: - /// - presenting: the view controller that presents the bottom sheet. - /// - sourceView: optional anchor view for the popover on iPad. - /// - sourceBarButtonItem: optional anchor bar button item for the popover on iPad. If non-nil, `sourceView` and `arrowDirections` are not used. - /// - arrowDirections: optional arrow directions for the popover on iPad. - public func show(from presenting: UIViewController, - sourceView: UIView? = nil, - sourceBarButtonItem: UIBarButtonItem? = nil, - arrowDirections: UIPopoverArrowDirection = .any) { - if UIDevice.isPad() { - - // If the anchor views are not set, or the user is using a larger text option - // we'll display the content in a sheet - if (sourceBarButtonItem == nil && sourceView == nil) || - traitCollection.preferredContentSizeCategory.isAccessibilityCategory { - modalPresentationStyle = .formSheet - } else { - modalPresentationStyle = .popover - - if let sourceBarButtonItem { - popoverPresentationController?.barButtonItem = sourceBarButtonItem - } else { - popoverPresentationController?.permittedArrowDirections = arrowDirections - popoverPresentationController?.sourceView = sourceView - popoverPresentationController?.sourceRect = sourceView?.bounds ?? .zero - } - - popoverPresentationController?.delegate = self - popoverPresentationController?.backgroundColor = view.backgroundColor - } - - } else { - transitioningDelegate = self - modalPresentationStyle = .custom - } - presenting.present(self, animated: true) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private lazy var gripButton: UIButton = { - let button = GripButton() - button.translatesAutoresizingMaskIntoConstraints = false - button.addTarget( - self, - action: #selector(buttonPressed), - for: .touchUpInside - ) - button.accessibilityLabel = NSLocalizedString("Dismiss", comment: "Accessibility label for button to dismiss a bottom sheet") - return button - }() - - private var stackView: UIStackView! - - private var defaultBrackgroundColor: UIColor { - return .systemBackground - } - - @objc func buttonPressed() { - dismiss(animated: true, completion: nil) - } - - override public func viewDidLoad() { - super.viewDidLoad() - - NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) - - NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) - - view.clipsToBounds = true - view.layer.cornerRadius = Constants.cornerRadius - view.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner] - view.backgroundColor = childViewController?.view.backgroundColor ?? defaultBrackgroundColor - - NSLayoutConstraint.activate([ - gripButton.heightAnchor.constraint(equalToConstant: Constants.gripHeight) - ]) - - guard let childViewController else { - return - } - - addChild(childViewController) - - stackView = UIStackView(arrangedSubviews: [ - gripButton, - childViewController.view - ]) - - stackView.setCustomSpacing(customHeaderSpacing ?? Constants.Header.spacing, after: gripButton) - - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.axis = .vertical - - refreshForTraits() - - view.addSubview(stackView) - view.pinSubviewToSafeArea(stackView, insets: Constants.Stack.insets) - - childViewController.didMove(toParent: self) - } - - open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - refreshForTraits() - } - - override public var preferredContentSize: CGSize { - set { - childViewController?.view.layoutIfNeeded() - - childViewController?.preferredContentSize = newValue - // Continue to make the assignment via super so preferredContentSizeDidChange is called on iPad popovers, resizing them as needed. - super.preferredContentSize = computePreferredContentSize() - } - get { - return computePreferredContentSize() - } - } - - func computePreferredContentSize() -> CGSize { - return (childViewController?.preferredContentSize ?? super.preferredContentSize) - } - - public override func preferredContentSizeDidChange(forChildContentContainer container: UIContentContainer) { - super.preferredContentSizeDidChange(forChildContentContainer: container) - // Update our preferred size in response to a child updating theres. - // While this leads to a recursive call, the sizes are the same preventing a loop. - // The assignment is needed in order for iPad popovers to correctly resize. - preferredContentSize = container.preferredContentSize - } - - override public func accessibilityPerformEscape() -> Bool { - dismiss(animated: true, completion: nil) - return true - } - - private func refreshForTraits() { - if presentingViewController?.traitCollection.horizontalSizeClass == .regular && presentingViewController?.traitCollection.verticalSizeClass != .compact { - gripButton.isHidden = true - additionalSafeAreaInsets = additionalSafeAreaInsetsRegular - } else { - gripButton.isHidden = false - additionalSafeAreaInsets = .zero - } - } - - @objc func keyboardWillShow(_ notification: NSNotification) { - guard childViewController?.presentedViewController == nil else { - return - } - - self.presentedVC?.transition(to: .expanded) - } - - @objc func keyboardWillHide(_ notification: NSNotification) { - guard childViewController?.presentedViewController == nil else { - return - } - - self.presentedVC?.transition(to: .collapsed) - } -} - -extension BottomSheetViewController: UIViewControllerTransitioningDelegate { - public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { - return BottomSheetAnimationController(transitionType: .presenting) - } - - public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { - - handleDismiss() - - return BottomSheetAnimationController(transitionType: .dismissing) - } - - public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { - return DrawerPresentationController(presentedViewController: presented, presenting: presenting) - } -} - -// MARK: - DrawerDelegate -extension BottomSheetViewController: DrawerPresentable { - public var allowsUserTransition: Bool { - return childViewController?.allowsUserTransition ?? true - } - - public var allowsTapToDismiss: Bool { - childViewController?.allowsTapToDismiss ?? true - } - - public var allowsDragToDismiss: Bool { - childViewController?.allowsDragToDismiss ?? true - } - - public var compactWidth: DrawerWidth { - childViewController?.compactWidth ?? .percentage(0.66) - } - - public var expandedHeight: DrawerHeight { - return childViewController?.expandedHeight ?? .maxHeight - } - - public var collapsedHeight: DrawerHeight { - return childViewController?.collapsedHeight ?? .contentHeight(200) - } - - public var scrollableView: UIScrollView? { - return childViewController?.scrollableView - } - - public func handleDismiss() { - if let childViewController { - childViewController.handleDismiss() - } - } -} - -extension BottomSheetViewController: UIPopoverPresentationControllerDelegate { - public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - handleDismiss() - } -} diff --git a/Modules/Sources/WordPressUI/BottomSheet/DrawerPresentationController.swift b/Modules/Sources/WordPressUI/BottomSheet/DrawerPresentationController.swift deleted file mode 100644 index 00af0a747449..000000000000 --- a/Modules/Sources/WordPressUI/BottomSheet/DrawerPresentationController.swift +++ /dev/null @@ -1,656 +0,0 @@ -import UIKit - -public enum DrawerPosition { - case expanded - case collapsed - case closed - case hidden -} - -public enum DrawerHeight { - // The maximum height for the screen - case maxHeight - - // Height is based on the specified margin from the top of the screen - case topMargin(CGFloat) - - // Height will be equal to the the content height value. A height of 0 will use the calculated height. - case contentHeight(CGFloat) - - // Height in the hidden state will be equal the screens height - case hidden - - // Calculate the intrisinc content based on the View Controller - case intrinsicHeight -} - -public enum DrawerWidth { - // Fills the whole screen width - case maxWidth - - // When in compact mode, fills a percentage of the screen - case percentage(CGFloat) - - // Width will be equal to the the content height value - case contentWidth(CGFloat) -} - -public protocol DrawerPresentable: AnyObject { - /// The height of the drawer when it's in the expanded position - var expandedHeight: DrawerHeight { get } - - /// The height of the drawer when it's in the collapsed position - var collapsedHeight: DrawerHeight { get } - - /// The width of the Drawer in compact screen - var compactWidth: DrawerWidth { get } - - /// Whether or not the user is allowed to swipe to switch between the expanded and collapsed position - var allowsUserTransition: Bool { get } - - /// Whether or not the user is allowed to drag to dismiss the drawer - var allowsDragToDismiss: Bool { get } - - /// Whether or not the user is allowed to tap outside the view to dismiss the drawer - var allowsTapToDismiss: Bool { get } - - /// A scroll view that should have its insets adjusted when the drawer is expanded/collapsed - var scrollableView: UIScrollView? { get } - - func handleDismiss() -} - -private enum Constants { - static let transitionDuration: TimeInterval = 0.5 - - static let flickVelocity: CGFloat = 300 - static let bounceAmount: CGFloat = 0.01 - - enum Defaults { - static let expandedHeight: DrawerHeight = .topMargin(20) - static let collapsedHeight: DrawerHeight = .contentHeight(0) - static let compactWidth: DrawerWidth = .percentage(0.66) - - static let allowsUserTransition: Bool = true - static let allowsTapToDismiss: Bool = true - static let allowsDragToDismiss: Bool = true - } -} - -public typealias DrawerPresentableViewController = DrawerPresentable & UIViewController - -public extension DrawerPresentable where Self: UIViewController { - // Default values - var allowsUserTransition: Bool { - return Constants.Defaults.allowsUserTransition - } - - var expandedHeight: DrawerHeight { - return Constants.Defaults.expandedHeight - } - - var collapsedHeight: DrawerHeight { - return Constants.Defaults.collapsedHeight - } - - var compactWidth: DrawerWidth { - return Constants.Defaults.compactWidth - } - - var scrollableView: UIScrollView? { - return nil - } - - var allowsDragToDismiss: Bool { - return Constants.Defaults.allowsDragToDismiss - } - - var allowsTapToDismiss: Bool { - return Constants.Defaults.allowsTapToDismiss - } - - // Helpers - - /// Try to determine the correct DrawerPresentationController to use - - /// Returns the `DrawerPresentationController` for a view controller if there is one - /// This tries to determine the correct one to use in the following order: - /// - The view controller - /// - The navController - /// - The navController parentViewController - /// - The views parentViewController - var presentedVC: DrawerPresentationController? { - let presentationController = self.presentationController as? DrawerPresentationController - let navigationPresentationController = navigationController?.presentationController as? DrawerPresentationController - let navParentPresetationController = navigationController?.parent?.presentationController as? DrawerPresentationController - let parentPresentationController = parent?.presentationController as? DrawerPresentationController - - return presentationController ?? navigationPresentationController ?? navParentPresetationController ?? parentPresentationController - } - - func handleDismiss() { } -} - -public class DrawerPresentationController: FancyAlertPresentationController { - override public var frameOfPresentedViewInContainerView: CGRect { - guard let containerView = self.containerView else { - return .zero - } - - var frame = containerView.frame - let y = collapsedYPosition - var width: CGFloat = containerView.bounds.width - (containerView.safeAreaInsets.left + containerView.safeAreaInsets.right) - - frame.origin.y = y - - /// If we're in a compact vertical size class, constrain the width a bit more so it doesn't get overly wide. - if let widthForCompactSizeClass = presentableViewController?.compactWidth, - traitCollection.verticalSizeClass == .compact { - - switch widthForCompactSizeClass { - case .percentage(let percentage): - width = width * percentage - case .contentWidth(let givenWidth): - width = givenWidth - case .maxWidth: - break - } - } - frame.size.width = width - - /// If we constrain the width, this centers the view by applying the appropriate insets based on width - frame.origin.x = ((containerView.bounds.width - width) / 2) - - return frame - } - - override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - coordinator.animate(alongsideTransition: { _ in - self.presentedView?.frame = self.frameOfPresentedViewInContainerView - self.transition(to: self.currentPosition) - }, completion: nil) - super.viewWillTransition(to: size, with: coordinator) - } - - /// Returns the current position of the drawer - public var currentPosition: DrawerPosition = .collapsed - - /// Returns the Y position of the drawer - public var yPosition: CGFloat? { - return presentedView?.frame.origin.y - } - - /// Animates between the drawer positions - /// - Parameter position: The position to animate to - public func transition(to position: DrawerPosition) { - currentPosition = position - - if position == .closed { - dismiss() - return - } - - var margin: CGFloat = 0 - - switch position { - case .expanded: - margin = expandedYPosition - - case .collapsed: - margin = collapsedYPosition - - case .hidden: - margin = hiddenYPosition - - default: - margin = 0 - } - - setTopMargin(margin) - } - - @objc func dismiss() { - presentedViewController.dismiss(animated: true, completion: nil) - } - - public override func presentationTransitionWillBegin() { - super.presentationTransitionWillBegin() - - configureScrollViewInsets() - } - - public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - transition(to: currentPosition) - } - - public override func presentationTransitionDidEnd(_ completed: Bool) { - super.presentationTransitionDidEnd(completed) - - configureScrollViewInsets() - } - - // MARK: - Internal Positions - // Helpers to calculate the Y positions for the drawer positions - - private var closedPosition: CGFloat { - guard let presentedView = self.presentedView else { - return 0 - } - - return presentedView.bounds.height - } - - private var collapsedYPosition: CGFloat { - let height = presentableViewController?.collapsedHeight ?? Constants.Defaults.collapsedHeight - - return topMargin(with: height) - } - - private var expandedYPosition: CGFloat { - let height = presentableViewController?.expandedHeight ?? Constants.Defaults.expandedHeight - - return topMargin(with: height) - } - - private var hiddenYPosition: CGFloat { - return topMargin(with: .hidden) - } - - /// Calculates the Y position for the view based on a DrawerHeight enum - /// - Parameter drawerHeight: The drawer height to calculate - private func topMargin(with drawerHeight: DrawerHeight) -> CGFloat { - var topMargin: CGFloat - - switch drawerHeight { - case .contentHeight(let height): - topMargin = calculatedTopMargin(for: height) - - case .topMargin(let margin): - topMargin = safeAreaInsets.top + margin - - case .maxHeight: - topMargin = safeAreaInsets.top - - case .intrinsicHeight: - // Force a layout to make sure we get the correct size from the views - presentedViewController.view.layoutIfNeeded() - - let height = presentedViewController.preferredContentSize.height - topMargin = calculatedTopMargin(for: height) - - case .hidden: - topMargin = UIScreen.main.bounds.height - } - - return topMargin - } - - // MARK: - Gestures - private lazy var tapGestureRecognizer: UITapGestureRecognizer = { - let gesture = UITapGestureRecognizer(target: self, action: #selector(self.dismiss(_:))) - gesture.delegate = self - return gesture - }() - - private lazy var panGestureRecognizer: UIPanGestureRecognizer = { - let panGesture = UIPanGestureRecognizer(target: self, action: #selector(self.pan(_:))) - panGesture.delegate = self - return panGesture - }() - - override public func containerViewWillLayoutSubviews() { - super.containerViewWillLayoutSubviews() - - addGestures() - observe(scrollView: presentableViewController?.scrollableView) - } - - /// Represents whether the view is animating to a new position - private var isPresentedViewAnimating = false - - /// Whether or not the presented view is anchored to the top of the screen - private var isPresentedViewAnchored: Bool { - if !isPresentedViewAnimating - && (presentedView?.frame.origin.y.rounded() ?? 0) <= expandedYPosition.rounded() { - return true - } - - return false - } - - private var dragStartPoint: CGPoint? - - /// Stores the current `contentOffset.y` for `presentableViewController.scrollableView` - /// See `haltScrolling` and `trackScrolling` for more information. - private var scrollViewYOffset: CGFloat = 0.0 - - /// An observer of the content offset for `presentableViewController.scrollableView` - private var scrollObserver: NSKeyValueObservation? - - deinit { - scrollObserver?.invalidate() - } -} - -// MARK: - Dragging -private extension DrawerPresentationController { - - private func addGestures() { - guard - let presentedView = self.presentedView, - let containerView = self.containerView - else { return } - - presentedView.addGestureRecognizer(panGestureRecognizer) - containerView.addGestureRecognizer(tapGestureRecognizer) - } - - /// Dismiss action for the tap gesture - /// Will prevent dismissal if the `allowsTapToDismiss` is false - /// - Parameter gesture: The tap gesture - @objc func dismiss(_ gesture: UIPanGestureRecognizer) { - let canDismiss = presentableViewController?.allowsTapToDismiss ?? Constants.Defaults.allowsTapToDismiss - - guard canDismiss else { - return - } - - dismiss() - } - - @objc func pan(_ gesture: UIPanGestureRecognizer) { - guard let presentedView = self.presentedView else { return } - - let isScrolling = presentableViewController?.scrollableView?.isScrolling == true - - guard (presentableViewController?.scrollableView?.contentOffset.y ?? 0) <= 0 || isScrolling == false else { return } - - /// Ignore the animation once panning begins so we can immediately interact - isPresentedViewAnimating = false - - let translation = gesture.translation(in: presentedView) - let allowsUserTransition = presentableViewController?.allowsUserTransition ?? Constants.Defaults.allowsUserTransition - let allowDragToDismiss = presentableViewController?.allowsDragToDismiss ?? Constants.Defaults.allowsDragToDismiss - - switch gesture.state { - case .began: - dragStartPoint = presentedView.frame.origin - - case .changed: - let startY = dragStartPoint?.y ?? 0 - var yTranslation = translation.y - - /// Slows the deceleration rate - if isScrolling && presentedView.frame.origin.y < expandedYPosition { - yTranslation /= 2.0 - } - - if !allowsUserTransition || !allowDragToDismiss { - let maxBounce: CGFloat = (startY * Constants.bounceAmount) - - if yTranslation < 0 { - yTranslation = max(yTranslation, maxBounce * -1) - } else { - if !allowDragToDismiss { - yTranslation = min(yTranslation, maxBounce) - } - } - } - - let maxY = topMargin(with: .maxHeight) - var yPosition = startY + yTranslation - if isScrolling { - /// During scrolling, ensure yPosition doesn't extend past the expanded position - yPosition = max(yPosition, expandedYPosition) - } - - let newMargin = max(yPosition, maxY) - setTopMargin(newMargin, animated: false) - - case .ended: - /// Helper closure to prevent user transition/dismiss - let transition: (DrawerPosition) -> Void = { pos in - if allowsUserTransition || pos == .closed && allowDragToDismiss { - self.transition(to: pos) - } else { - // Reset to the original position - self.transition(to: self.currentPosition) - } - } - - let velocity = gesture.velocity(in: presentedView).y - let startY = dragStartPoint?.y ?? 0 - - let currentPosition = (startY + translation.y) - let position = closestPosition(for: currentPosition) - - // Determine how to handle flicking of the view - if (abs(velocity) - Constants.flickVelocity) > 0 { - // Flick up - if velocity < 0 { - transition(.expanded) - } else { - if position == .expanded { - transition(.collapsed) - } else { - transition(.closed) - } - } - - return - } - - transition(position) - - dragStartPoint = nil - - default: - return - } - } -} - -// MARK: - Scrolling -private extension DrawerPresentationController { - - /// Adds an observer for the scroll view's content offset. - /// Track scrolling without overriding the `scrollView` delegate - /// - Parameter scrollView: The scroll view whose content offset will be tracked. - func observe(scrollView: UIScrollView?) { - scrollObserver?.invalidate() - scrollObserver = scrollView?.observe(\.contentOffset, options: .old) { [weak self] scrollView, change in - - /// In case there are two containerViews in the same presentation - guard self?.containerView != nil - else { return } - - self?.didPan(on: scrollView, change: change) - } - } - - /// Handles scroll view content offset changes - /// - Parameters: - /// - scrollView: The scroll view whose content offset is changing. - /// - change: The change representing the old and new content offsets. - func didPan(on scrollView: UIScrollView, change: NSKeyValueObservedChange) { - - guard - !presentedViewController.isBeingDismissed, - !presentedViewController.isBeingPresented - else { return } - - if !isPresentedViewAnchored && scrollView.contentOffset.y > 0 { - - /// Halts scrolling when scrolling down from expanded or up from compact - haltScrolling(scrollView) - - } else if scrollView.isScrolling { - - if isPresentedViewAnchored { - /// Allow normal scrolling (with tracking) - trackScrolling(scrollView) - } else { - /// Halts scrolling when panning down from expanded - haltScrolling(scrollView) - } - - } else { - /// Allow normal scrolling (with tracking) - trackScrolling(scrollView) - } - } - - /// Stops scrolling behavior on `scrollView` and anchors to `scrollViewYOffset`. - /// - Parameter scrollView: The scroll view to stop and anchor anchor - private func haltScrolling(_ scrollView: UIScrollView) { - // Only halt the scrolling if we haven't halted it before - guard scrollView.showsVerticalScrollIndicator else { - return - } - - scrollView.setContentOffset(CGPoint(x: 0, y: scrollViewYOffset), animated: false) - scrollView.showsVerticalScrollIndicator = false - } - - /// Tracks and saves the y offset of `scrollView` in `scrollViewYOffset`. - /// Used later by `haltScrolling` to adjust the scroll view to `scrollViewYOffset` to give the appearance of the sticking position. - /// - Parameter scrollView: The scroll view to track. - private func trackScrolling(_ scrollView: UIScrollView) { - scrollViewYOffset = max(scrollView.contentOffset.y, 0) - scrollView.showsVerticalScrollIndicator = true - } -} - -private extension UIScrollView { - /// A flag to determine if a scroll view is scrolling - var isScrolling: Bool { - return isDragging && !isDecelerating || isTracking - } -} - -extension DrawerPresentationController: UIGestureRecognizerDelegate { - public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - - guard tapGestureRecognizer == gestureRecognizer else { return true } - - /// Shouldn't happen; should always have container & presented view when tapped - guard - let containerView, - let presentedView, - currentPosition != .hidden - else { - return false - } - - let touchPoint = touch.location(in: containerView) - let isInPresentedView = presentedView.frame.contains(touchPoint) - - /// Do not accept the touch if inside of the presented view - return (gestureRecognizer == tapGestureRecognizer) && isInPresentedView == false - } - - public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { - return false - } - - public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - return otherGestureRecognizer.view == presentableViewController?.scrollableView - } -} - -// MARK: - Private: Helpers -private extension DrawerPresentationController { - - private func configureScrollViewInsets() { - guard - let scrollView = presentableViewController?.scrollableView, - let presentedView = self.presentedView, - let presentingView = presentingViewController.view - else { return } - - let bottom = presentingView.safeAreaInsets.bottom - let margin = presentedView.frame.origin.y + bottom - - scrollView.contentInset.bottom = margin - } - - private var presentableViewController: DrawerPresentable? { - return presentedViewController as? DrawerPresentable - } - - private func calculatedTopMargin(for height: CGFloat) -> CGFloat { - guard let containerView = self.containerView else { - return 0 - } - - let bounds = containerView.bounds - let margin = bounds.maxY - (safeAreaInsets.bottom + ((height > 0) ? height : (bounds.height * 0.5))) - - // Limit the max height - return max(margin, safeAreaInsets.top) - } - - private func setTopMargin(_ margin: CGFloat, animated: Bool = true) { - guard let presentedView = self.presentedView else { - return - } - - var frame = presentedView.frame - frame.origin.y = margin - - let animations = { - presentedView.frame = frame - - self.configureScrollViewInsets() - } - - if animated { - animate(animations) - } else { - animations() - } - } - - private var safeAreaInsets: UIEdgeInsets { - guard let rootViewController = self.rootViewController else { - return .zero - } - - return rootViewController.view.safeAreaInsets - } - - func closestPosition(for yPosition: CGFloat) -> DrawerPosition { - let positions = [closedPosition, collapsedYPosition, expandedYPosition] - let closestVal = positions.min(by: { abs(yPosition - $0) < abs(yPosition - $1) }) ?? yPosition - - var returnPosition: DrawerPosition = .closed - - if closestVal == expandedYPosition { - returnPosition = .expanded - } else if closestVal == collapsedYPosition { - returnPosition = .collapsed - } - - return returnPosition - } - - private func animate(_ animations: @escaping () -> Void) { - isPresentedViewAnimating = true - UIView.animate(withDuration: Constants.transitionDuration, - delay: 0, - usingSpringWithDamping: 0.8, - initialSpringVelocity: 0, - options: [.curveEaseInOut, .allowUserInteraction], - animations: animations) { [weak self] _ in - self?.isPresentedViewAnimating = false - } - } - - private var rootViewController: UIViewController? { - guard let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication - else { return nil } - - return application.keyWindow?.rootViewController - } -} diff --git a/Modules/Sources/WordPressUI/Extensions/SwiftUI+Extensions.swift b/Modules/Sources/WordPressUI/Extensions/SwiftUI+Extensions.swift index 91d904394483..5e39a6ad8f8a 100644 --- a/Modules/Sources/WordPressUI/Extensions/SwiftUI+Extensions.swift +++ b/Modules/Sources/WordPressUI/Extensions/SwiftUI+Extensions.swift @@ -1,5 +1,25 @@ +import UIKit import SwiftUI public extension EdgeInsets { static let zero = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) } + +private struct PresentingViewControllerKey: EnvironmentKey { + static let defaultValue = WeakEnvironmentValueWrapper() +} + +extension EnvironmentValues { + public var presentingViewController: UIViewController? { + get { + self[PresentingViewControllerKey.self].value ?? UIViewController.topViewController + } + set { + self[PresentingViewControllerKey.self].value = newValue + } + } +} + +private final class WeakEnvironmentValueWrapper { + weak var value: T? +} diff --git a/Modules/Sources/WordPressUI/Extensions/UIButton+Extensions.swift b/Modules/Sources/WordPressUI/Extensions/UIButton+Extensions.swift new file mode 100644 index 000000000000..4c7b6581fb84 --- /dev/null +++ b/Modules/Sources/WordPressUI/Extensions/UIButton+Extensions.swift @@ -0,0 +1,14 @@ +import UIKit + +extension UIButton.Configuration { + public static func primary() -> UIButton.Configuration { + var configuration = UIButton.Configuration.borderedProminent() + configuration.titleTextAttributesTransformer = .init { attributes in + var attributes = attributes + attributes.font = UIFont.preferredFont(forTextStyle: .headline) + return attributes + } + configuration.buttonSize = .large + return configuration + } +} diff --git a/Modules/Sources/WordPressUI/Extensions/UIImage+Color.swift b/Modules/Sources/WordPressUI/Extensions/UIImage+Color.swift index 4437aacdbe07..73f507749adb 100644 --- a/Modules/Sources/WordPressUI/Extensions/UIImage+Color.swift +++ b/Modules/Sources/WordPressUI/Extensions/UIImage+Color.swift @@ -4,13 +4,17 @@ public extension UIImage { /// Create an image of the given `size` that's made of a single `color`. /// - /// Size is in points. + /// - parameter size: Size in points. convenience init(color: UIColor, size: CGSize = CGSize(width: 1.0, height: 1.0)) { - let image = UIGraphicsImageRenderer(size: size).image { rendererContext in + let image = UIGraphicsImageRenderer(size: size).image { context in color.setFill() - rendererContext.fill(CGRect(origin: .zero, size: size)) + context.fill(CGRect(origin: .zero, size: size)) + } + if let cgImage = image.cgImage { + self.init(cgImage: cgImage, scale: image.scale, orientation: .up) + } else { + assertionFailure("faield to render image with color") + self.init() } - - self.init(cgImage: image.cgImage!) // Force because there's no reason that the `cgImage` should be nil } } diff --git a/Modules/Sources/WordPressUI/Extensions/UIImageView+Blavatar.swift b/Modules/Sources/WordPressUI/Extensions/UIImageView+Blavatar.swift deleted file mode 100644 index bc4b0414a71f..000000000000 --- a/Modules/Sources/WordPressUI/Extensions/UIImageView+Blavatar.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation -import UIKit - -public extension UIImageView { - - /// Downloads a resized Blavatar, meant to perfectly fit the UIImageView's Dimensions - /// - /// - Parameter url: The URL of the target blavatar - /// - func downloadBlavatar(from url: URL) { - var components = URLComponents(url: url, resolvingAgainstBaseURL: true) - components?.query = String(format: Downloader.blavatarResizeFormat, blavatarSize) - - guard let updatedURL = components?.url else { - assertionFailure() - return - } - - let size = CGSize(width: blavatarSizeInPoints, height: blavatarSizeInPoints) - downloadResizedImage(from: updatedURL, pointSize: size) - } - - /// Returns the desired Blavatar Side-Size, in pixels - /// - private var blavatarSize: Int { - return blavatarSizeInPoints * Int(mainScreenScale) - } - - /// Returns the desired Blavatar Side-Size, in points - /// - private var blavatarSizeInPoints: Int { - var size = Downloader.defaultImageSize - - if !bounds.size.equalTo(.zero) { - size = max(bounds.width, bounds.height) - } - - return Int(size) - } - - /// Returns the Main Screen Scale - /// - private var mainScreenScale: CGFloat { - return UIScreen.main.scale - } - - /// Private helper structure - /// - private struct Downloader { - /// Default Blavatar Image Size - /// - static let defaultImageSize = CGFloat(40) - - /// Blavatar Resize Query FormatString - /// - static let blavatarResizeFormat = "d=404&s=%d" - } -} diff --git a/Modules/Sources/WordPressUI/Resources/Assets.xcassets/Contents.json b/Modules/Sources/WordPressUI/Resources/Assets.xcassets/Contents.json index da4a164c9186..73c00596a7fc 100644 --- a/Modules/Sources/WordPressUI/Resources/Assets.xcassets/Contents.json +++ b/Modules/Sources/WordPressUI/Resources/Assets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Modules/Sources/WordPressUI/Resources/Assets.xcassets/vector.imageset/Contents.json b/Modules/Sources/WordPressUI/Resources/Assets.xcassets/vector.imageset/Contents.json new file mode 100644 index 000000000000..4bdc4947b421 --- /dev/null +++ b/Modules/Sources/WordPressUI/Resources/Assets.xcassets/vector.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Vector.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Modules/Sources/WordPressUI/Resources/Assets.xcassets/vector.imageset/Vector.pdf b/Modules/Sources/WordPressUI/Resources/Assets.xcassets/vector.imageset/Vector.pdf new file mode 100644 index 000000000000..d4c9dab9b0a3 Binary files /dev/null and b/Modules/Sources/WordPressUI/Resources/Assets.xcassets/vector.imageset/Vector.pdf differ diff --git a/Modules/Sources/WordPressUI/Views/BottomToolbarView.swift b/Modules/Sources/WordPressUI/Views/BottomToolbarView.swift new file mode 100644 index 000000000000..14a7bb96c38e --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/BottomToolbarView.swift @@ -0,0 +1,86 @@ +import UIKit +import Combine + +/// A custom bottom toolbar implementation that, unlike the native toolbar, +/// can accommodate larger buttons but shares a lot of its behavior including +/// edge appearance. +public class BottomToolbarView: UIView { + private let separator = SeparatorView.horizontal() + private let effectView = UIVisualEffectView() + private var isEdgeAppearanceEnabled = false + private weak var scrollView: UIScrollView? + private var cancellable: AnyCancellable? + + public let contentView = UIView() + + public override init(frame: CGRect) { + super.init(frame: frame) + + addSubview(effectView) + addSubview(separator) + + separator.pinEdges([.top, .horizontal]) + effectView.pinEdges() + + effectView.contentView.addSubview(contentView) + + contentView.pinEdges(to: effectView.contentView.safeAreaLayoutGuide, insets: UIEdgeInsets(.all, 20)) + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func layoutSubviews() { + super.layoutSubviews() + + updateScrollViewContentInsets() + } + + public override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + + updateScrollViewContentInsets() + } + + /// - warning: If you use this view, you'll typically need to take over the + /// scroll view content inset adjustment. + public func configure(in viewController: UIViewController, scrollView: UIScrollView) { + viewController.view.addSubview(self) + pinEdges([.horizontal, .bottom]) + self.scrollView = scrollView + + cancellable = scrollView.publisher(for: \.contentOffset, options: [.new]).sink { [weak self] offset in + self?.updateEdgeAppearance(animated: true) + } + updateScrollViewContentInsets() + updateEdgeAppearance(animated: false) + } + + private func updateEdgeAppearance(animated: Bool) { + guard let scrollView, let superview else { return } + + let isContentOverlapping = superview.convert(scrollView.contentLayoutGuide.layoutFrame, from: scrollView).maxY > (frame.minY + 16) + setEdgeAppearanceEnabled(!isContentOverlapping, animated: animated) + } + + private func setEdgeAppearanceEnabled(_ isEnabled: Bool, animated: Bool) { + guard isEdgeAppearanceEnabled != isEnabled else { return } + isEdgeAppearanceEnabled = isEnabled + + UIView.animate(withDuration: animated ? 0 : 0.33, delay: 0.0, options: [.allowUserInteraction, .beginFromCurrentState]) { + self.effectView.effect = isEnabled ? nil : UIBlurEffect(style: .extraLight) + self.separator.alpha = isEnabled ? 0 : 1 + } + } + + // The toolbar does no extend the safe area because it itself depends on it, + // so it resorts to changing `contentInset` instead. + private func updateScrollViewContentInsets() { + guard let scrollView else { return } + let bottomInset = bounds.height - safeAreaInsets.bottom + if scrollView.contentInset.bottom != bottomInset { + scrollView.contentInset.bottom = bottomInset + } + } +} diff --git a/Modules/Sources/WordPressUI/Views/SeparatorView.swift b/Modules/Sources/WordPressUI/Views/SeparatorView.swift new file mode 100644 index 000000000000..8d0d8b888ddb --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/SeparatorView.swift @@ -0,0 +1,25 @@ +import UIKit + +public final class SeparatorView: UIView { + public static func horizontal() -> SeparatorView { + let view = SeparatorView() + view.heightAnchor.constraint(equalToConstant: 0.5).isActive = true + return view + } + + public static func vertical() -> SeparatorView { + let view = SeparatorView() + view.widthAnchor.constraint(equalToConstant: 0.5).isActive = true + return view + } + + public override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .separator + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Modules/Sources/WordPressUI/Views/Settings/Experimental Features/ExperimentalFeaturesList.swift b/Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/ExperimentalFeaturesList.swift similarity index 100% rename from Modules/Sources/WordPressUI/Views/Settings/Experimental Features/ExperimentalFeaturesList.swift rename to Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/ExperimentalFeaturesList.swift diff --git a/Modules/Sources/WordPressUI/Views/Settings/Experimental Features/ExperimentalFeaturesViewModel.swift b/Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/ExperimentalFeaturesViewModel.swift similarity index 100% rename from Modules/Sources/WordPressUI/Views/Settings/Experimental Features/ExperimentalFeaturesViewModel.swift rename to Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/ExperimentalFeaturesViewModel.swift diff --git a/Modules/Sources/WordPressUI/Views/Settings/Experimental Features/Feature.swift b/Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/Feature.swift similarity index 100% rename from Modules/Sources/WordPressUI/Views/Settings/Experimental Features/Feature.swift rename to Modules/Sources/WordPressUI/Views/Settings/ExperimentalFeatures/Feature.swift diff --git a/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconView.swift b/Modules/Sources/WordPressUI/Views/SiteIconView.swift similarity index 69% rename from WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconView.swift rename to Modules/Sources/WordPressUI/Views/SiteIconView.swift index ae4bd41274e0..9816b8a89703 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconView.swift +++ b/Modules/Sources/WordPressUI/Views/SiteIconView.swift @@ -1,14 +1,17 @@ import UIKit import SwiftUI -import DesignSystem -import WordPressShared +import AsyncImageKit -struct SiteIconView: View { - let viewModel: SiteIconViewModel +public struct SiteIconView: View { + public let viewModel: SiteIconViewModel @Environment(\.siteIconBackgroundColor) private var backgroundColor - var body: some View { + public init(viewModel: SiteIconViewModel) { + self.viewModel = viewModel + } + + public var body: some View { contents .clipShape(RoundedRectangle(cornerRadius: 6)) } @@ -55,7 +58,7 @@ struct SiteIconView: View { private var failureStateView: some View { backgroundColor.overlay { - Image.DS.icon(named: .vector) + Image("vector", bundle: .module) .resizable() .frame(width: 18, height: 18) .tint(Color(.tertiaryLabel)) @@ -68,18 +71,52 @@ private struct SiteIconViewBackgroundColorKey: EnvironmentKey { } extension EnvironmentValues { - var siteIconBackgroundColor: Color { + public var siteIconBackgroundColor: Color { get { self[SiteIconViewBackgroundColorKey.self] } set { self[SiteIconViewBackgroundColorKey.self] = newValue } } } +// MARK: - SiteIconViewModel + +public struct SiteIconViewModel { + public var imageURL: URL? + public var firstLetter: Character? + public var size: Size + public var host: MediaHostProtocol? + + public enum Size { + case small + case regular + case large + + public var width: CGFloat { + switch self { + case .small: 28 + case .regular: 40 + case .large: 72 + } + } + + public var size: CGSize { + CGSize(width: width, height: width) + } + } + + public init(imageURL: URL? = nil, firstLetter: Character? = nil, size: Size = .regular, host: MediaHostProtocol? = nil) { + self.imageURL = imageURL + self.firstLetter = firstLetter + self.size = size + self.host = host + } +} + // MARK: - SiteIconHostingView (UIKit) -final class SiteIconHostingView: UIView { +public final class SiteIconHostingView: UIView { private let viewModel = SiteIconHostingViewModel() - override init(frame: CGRect) { + public override init(frame: CGRect) { super.init(frame: frame) let host = UIHostingController(rootView: _SiteIconHostingView(viewModel: viewModel)) @@ -89,11 +126,11 @@ final class SiteIconHostingView: UIView { host.view.pinSubviewToAllEdges(self) } - required init?(coder: NSCoder) { + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func setIcon(with viewModel: SiteIconViewModel) { + public func setIcon(with viewModel: SiteIconViewModel) { self.viewModel.icon = viewModel } } diff --git a/Modules/Sources/WordPressUI/Views/SpacerView.swift b/Modules/Sources/WordPressUI/Views/SpacerView.swift new file mode 100644 index 000000000000..2dd3e6bd46d6 --- /dev/null +++ b/Modules/Sources/WordPressUI/Views/SpacerView.swift @@ -0,0 +1,54 @@ +import UIKit + +public final class SpacerView: UIView { + public convenience init(minWidth: CGFloat) { + self.init() + + widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth).isActive = true + } + + public convenience init(minHeight: CGFloat) { + self.init() + + heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight).isActive = true + } + + public convenience init(width: CGFloat) { + self.init() + + widthAnchor.constraint(equalToConstant: width).isActive = true + } + + public convenience init(height: CGFloat) { + self.init() + + heightAnchor.constraint(equalToConstant: height).isActive = true + } + + public override init(frame: CGRect) { + super.init(frame: .zero) + + // Make sure it compresses or expands before any other views if needed. + setContentCompressionResistancePriority(.init(10), for: .horizontal) + setContentCompressionResistancePriority(.init(10), for: .vertical) + setContentHuggingPriority(.init(10), for: .horizontal) + setContentHuggingPriority(.init(10), for: .vertical) + } + + public override var intrinsicContentSize: CGSize { + CGSizeMake(0, 0) // Avoid ambiguous layout + } + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + override public class var layerClass: AnyClass { + CATransformLayer.self // Draws nothing + } + + override public var backgroundColor: UIColor? { + get { return nil } + set { /* Do nothing */ } + } +} diff --git a/Modules/Tests/WordPressMediaTests/ImageDownloaderTests.swift b/Modules/Tests/AsyncImageKitTests/ImageDownloaderTests.swift similarity index 96% rename from Modules/Tests/WordPressMediaTests/ImageDownloaderTests.swift rename to Modules/Tests/AsyncImageKitTests/ImageDownloaderTests.swift index 00e9f4c33f86..95e967a166c2 100644 --- a/Modules/Tests/WordPressMediaTests/ImageDownloaderTests.swift +++ b/Modules/Tests/AsyncImageKitTests/ImageDownloaderTests.swift @@ -1,6 +1,6 @@ import UIKit import Testing -import WordPressMedia +import AsyncImageKit import WordPressTesting import OHHTTPStubs import OHHTTPStubsSwift @@ -27,7 +27,7 @@ import OHHTTPStubsSwift // WHEN let options = ImageRequestOptions( - size: CGSize(width: 256, height: 256), + size: ImageSize(width: 256, height: 256), isMemoryCacheEnabled: false, isDiskCacheEnabled: false ) @@ -46,7 +46,7 @@ import OHHTTPStubsSwift // WHEN let options = ImageRequestOptions( - size: CGSize(width: 256, height: 256), + size: ImageSize(width: 256, height: 256), isMemoryCacheEnabled: false, isDiskCacheEnabled: false ) @@ -72,7 +72,7 @@ import OHHTTPStubsSwift let imageURL = try #require(URL(string: "https://example.files.wordpress.com/2023/09/image.jpg")) try mockResponse(withResource: "test-image", fileExtension: "jpg") - let size = CGSize(width: 256, height: 256) + let size = ImageSize(width: 256, height: 256) let options = ImageRequestOptions( size: size, isMemoryCacheEnabled: true, diff --git a/Modules/Tests/WordPressUITests/BottomSheet/BottomSheetViewControllerTests.swift b/Modules/Tests/WordPressUITests/BottomSheet/BottomSheetViewControllerTests.swift deleted file mode 100644 index 3cfc8457fc1f..000000000000 --- a/Modules/Tests/WordPressUITests/BottomSheet/BottomSheetViewControllerTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -import XCTest - -@testable import WordPressUI - -class BottomSheetViewControllerTests: XCTestCase { - - /// - Add the given ViewController as a child View Controller - /// - func testAddTheGivenViewControllerAsAChildViewController() { - let viewController = BottomSheetPresentableViewController() - let bottomSheet = BottomSheetViewController(childViewController: viewController) - - bottomSheet.viewDidLoad() - - XCTAssertTrue(bottomSheet.children.contains(viewController)) - } - - /// - Add the given ViewController view to the subviews of the Bottom Sheet - /// - func testAddGivenVCViewToTheBottomSheetSubviews() { - let viewController = BottomSheetPresentableViewController() - let bottomSheet = BottomSheetViewController(childViewController: viewController) - - bottomSheet.viewDidLoad() - - XCTAssertTrue(bottomSheet.view.subviews.flatMap { $0.subviews }.contains(viewController.view)) - } -} - -private class BottomSheetPresentableViewController: UIViewController, DrawerPresentable { - var initialHeight: CGFloat = 0 -} diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 6905519375b2..fc1aa570950f 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,28 +1,60 @@ 25.7 ----- -* [**] Enable history navigation (undo and redo) for the experimental editor. [#23961] - -25.6 ------ * [**] Add Image Playground support (part of Apple Intelligence suite) for adding images to your posts, generated featured image, site icons, and more [#23688] +* [**] Enable history navigation (undo and redo) for the experimental editor. [#23961] +* [**] Various bug fixes and improvements in the new experimental editor [#23919] * [**] Enhance the Gravatar Quick Editor by adding features that allow users to delete and share their avatars. [#23868] +* [**] Add new lightbox screen for images with modern transitions and enhanced performance [#23922] * [**] Add the capability to create an avatar using Apple Image Playground through the Gravatar Quick Editor. [#23868] -* [*] Various bug fixes and improvements in the new experimental editor [#23919] -* [*] [internal] Update Gravatar SDK to 3.0.0 [#23701] -* [*] (Hidden under a feature flag) User Management for self-hosted sites. [#23768] * [*] Fix sorting in Stats Subscriber Emails (latest first) [#23913] -* [*] Add URL and ID to the Media details screen, add IDs for posts [#23887] -* [*] Reader: Enable quick access to notifications on iPad [#23882] -* [*] Add support for restricted posts in Reader [#23853] -* [*] Fix minor appearance issues in the Blaze campaign list [#23891] -* [*] Improve the sidebar animations and layout on some iPad models [#23886] * [*] Reader: Fix a couple of rare crashes in Reader [#23907] -* [*] Reader: Fix an issue with posts shown embedded in the notifications popover on iPad [#23889] +* [*] Add prefetching to Reader streams [#23928] * [*] Reader: The post cover now uses the standard aspect ratio for covers, so there is no jumping. There are also a few minor improvements to the layout and animations of the cover and the header [#23897, #23909] * [*] Reader: Move the "Reading Preferences" button to the "More" menu [#23897] * [*] Reader: Fix an issue with empty state views being non-scrollable in streams [#23908] * [*] Reader: Hide post toolbar when reading an article and fix like button animations [#23909] * [*] Reader: Fix off-by-one error in post details like counter when post is liked by you [#23912] +* [*] Fix an issue with blogging reminders prompt not being shown after publishing a new post [#23930] +* [*] Fix transitions in Blogging Reminders flow, improve accessibility, add close buttons [#23931] +* [*] Fix an issue with compliance popover not dismissing for self-hosted site [#23932] +* [*] Fix dynamic type support in the compliance popover [#23932] +* [*] Improve transisions and interactive dismiss gestures for sheets [#23933] +* [*] Add "Share" action to site link context menu on dashboard [#23935] +* [*] Fix layout issues in Privacy Settings section of App Settings [#23936] +* [*] Fix incorrect chevron icons direction in RTL languages [#23940] +* [*] Fix an issue with clear navigation bar background in revision browser [#23941] +* [*] Fix an issue with comments being lost on request failure [#23942] +* [*] Fix an issue with Referrers in Stats showing invalid icons [#23943] +* [*] Integrate zoom transitions in Themes, Reader [#23945, #23947] +* [*] Fix an issue with site icons cropped in share extensions [#23950] +* [*] Show selected filter in the Discover navigation bar [#23956] +* [*] Enable fast deceleration for filters on the Discover tab [#23954] +* [*] Disable universal links support for QR code login. You can only scan the codes using the app now. [#23953] +* [*] Add scroll-to-top button to Reader streams [#23957] +* [*] Add a quick way to replace a featured image for a post [#23962] +* [*] Fix an issue with posts in Reader sometimes showing incorrect covers [#23914] +* [*] Fix non-stable order in Posts and Pages section in Stats [#23915] +* [*] (P2) Reader: Fix an issue with a missing "Mark as Read/Unread" button that was removed in the previous release [#23917] +* [*] (P2) Reader: Show "read" status for P2 posts in the feeds [#23917] +* [*] Fix some missing or invalid social sharing icons [#23918] +* [*] Fix small tap area for “More” button in the Subscriptions screen in Reader [#23964] +* [*] Fix “Notification Settings” for individual posts sometimes being clipped [#23964] +* [*] Add context menu (long-press) and previews for subscriptions with quick access to “Share”, “Copy Link”, “Add to Favorites”, “Notification Settings”, and “Unsubscribe” buttons in both the Subscriptions view and the Sidebar in Reader [#23964] +* [*] Fix an issue with fullscreen button in reply view clipped by the notch [#23965] +* [*] Remove "Lazy Images" option that is no longer part of the Jetpack plugin [#23966] +* [*] Fix an issue with "Speed up your site" section not refreshing (fails silently) [#23966] + +25.6 +----- +* [*] [internal] Update Gravatar SDK to 3.0.0 [#23701] +* [*] Use the Gravatar Quick Editor to update the avatar [#23729] +* [*] (Hidden under a feature flag) User Management for self-hosted sites. [#23768] +* [*] Add URL and ID to the Media details screen, add IDs for posts [#23887] +* [*] Enable quick access to notifications from Reader on iPad [#23882] +* [*] Add support for restricted posts in Reader [#23853] +* [*] Fix minor appearance issues in the Blaze campaign list [#23891] +* [*] Improve the sidebar animations and layout on some iPad models [#23886] +* [*] Fix an issue with posts shown embedded in the notifications popover on iPad [#23889] 25.5 ----- diff --git a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3b44f631fb7b..c378d04e523a 100644 --- a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4cacb81eb20e7d20d1b25f261d9303a24f725104ed3b36743fc2707b7473a93d", + "originHash" : "cc23994fa7cdcf18782712f28e823bb56a70994e186e61bb5ba912f1386d66a7", "pins" : [ { "identity" : "alamofire", @@ -10,15 +10,6 @@ "version" : "5.9.1" } }, - { - "identity" : "alamofireimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Alamofire/AlamofireImage", - "state" : { - "revision" : "1eaf3b6c6882bed10f6e7b119665599dd2329aa1", - "version" : "4.3.0" - } - }, { "identity" : "automattic-tracks-ios", "kind" : "remoteSourceControl", @@ -314,6 +305,15 @@ "version" : "2.3.1" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -383,7 +383,7 @@ "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { "branch" : "wpios-edition", - "revision" : "77b3f5e98da1e837e3983615c9fd1b38d66e3084" + "revision" : "a9a057ea6fba8080c146497336951dc805409771" } }, { diff --git a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift index 4d7bbf291541..41de9ac06460 100644 --- a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift +++ b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift @@ -13,8 +13,8 @@ extension WPStyleGuide { // looking the same on newer versions of iOS. UIStackView.appearance().backgroundColor = .clear - UIWindow.appearance().tintColor = UIAppColor.brand - UISwitch.appearance().onTintColor = UIAppColor.brand + UIWindow.appearance().tintColor = UIAppColor.primary + UISwitch.appearance().onTintColor = UIAppColor.primary UITableView.appearance().sectionHeaderTopPadding = 0 @@ -164,7 +164,7 @@ extension WPStyleGuide { @objc class func configureTableViewActionCell(_ cell: UITableViewCell?) { configureTableViewCell(cell) - cell?.textLabel?.textColor = UIAppColor.brand + cell?.textLabel?.textColor = UIAppColor.primary } @objc diff --git a/WordPress/Classes/Extensions/Media/CircularProgressView+ActivityIndicatorType.swift b/WordPress/Classes/Extensions/Media/CircularProgressView+ActivityIndicatorType.swift deleted file mode 100644 index b05174a15acc..000000000000 --- a/WordPress/Classes/Extensions/Media/CircularProgressView+ActivityIndicatorType.swift +++ /dev/null @@ -1,13 +0,0 @@ -import UIKit - -extension CircularProgressView: ActivityIndicatorType { - func startAnimating() { - isHidden = false - state = .indeterminate - } - - func stopAnimating() { - isHidden = true - state = .stopped - } -} diff --git a/WordPress/Classes/Extensions/NSAttributedString+Helpers.swift b/WordPress/Classes/Extensions/NSAttributedString+Helpers.swift index 1bb9ef503f36..9cc05988f5c4 100644 --- a/WordPress/Classes/Extensions/NSAttributedString+Helpers.swift +++ b/WordPress/Classes/Extensions/NSAttributedString+Helpers.swift @@ -1,7 +1,7 @@ import UIKit import MobileCoreServices import UniformTypeIdentifiers -import WordPressMedia +import AsyncImageKit @objc public extension NSAttributedString { diff --git a/WordPress/Classes/Models/Blog/Blog+Capabilities.swift b/WordPress/Classes/Models/Blog/Blog+Capabilities.swift index ae00fa1621e7..7ff568add020 100644 --- a/WordPress/Classes/Models/Blog/Blog+Capabilities.swift +++ b/WordPress/Classes/Models/Blog/Blog+Capabilities.swift @@ -6,21 +6,21 @@ extension Blog { /// Enumeration that contains all of the Blog's available capabilities. /// public enum Capability: String { - case DeleteOthersPosts = "delete_others_posts" - case DeletePosts = "delete_posts" - case EditOthersPages = "edit_others_pages" - case EditOthersPosts = "edit_others_posts" - case EditPages = "edit_pages" - case EditPosts = "edit_posts" - case EditThemeOptions = "edit_theme_options" - case EditUsers = "edit_users" - case ListUsers = "list_users" - case ManageCategories = "manage_categories" - case ManageOptions = "manage_options" - case PromoteUsers = "promote_users" - case PublishPosts = "publish_posts" - case UploadFiles = "upload_files" - case ViewStats = "view_stats" + case DeleteOthersPosts = "delete_others_posts" + case DeletePosts = "delete_posts" + case EditOthersPages = "edit_others_pages" + case EditOthersPosts = "edit_others_posts" + case EditPages = "edit_pages" + case EditPosts = "edit_posts" + case EditThemeOptions = "edit_theme_options" + case EditUsers = "edit_users" + case ListUsers = "list_users" + case ManageCategories = "manage_categories" + case ManageOptions = "manage_options" + case PromoteUsers = "promote_users" + case PublishPosts = "publish_posts" + case UploadFiles = "upload_files" + case ViewStats = "view_stats" } /// Returns true if a given capability is enabled. False otherwise diff --git a/WordPress/Classes/Models/Blog/Blog.h b/WordPress/Classes/Models/Blog/Blog.h index 9cecad79e951..a82a4225b10c 100644 --- a/WordPress/Classes/Models/Blog/Blog.h +++ b/WordPress/Classes/Models/Blog/Blog.h @@ -194,20 +194,20 @@ typedef NS_ENUM(NSInteger, SiteVisibility) { * * @warn For WordPress.com or Jetpack Managed sites this will be nil. Use usernameForSite instead */ -@property (nonatomic, strong, readwrite, nullable) NSString *username; -@property (nonatomic, strong, readwrite, nullable) NSString *password; +@property (nonatomic, strong, readwrite, nullable) NSString *username; +@property (nonatomic, strong, readwrite, nullable) NSString *password; // Readonly Properties -@property (nonatomic, weak, readonly, nullable) NSArray *sortedPostFormatNames; -@property (nonatomic, weak, readonly, nullable) NSArray *sortedPostFormats; -@property (nonatomic, weak, readonly, nullable) NSArray *sortedConnections; +@property (nonatomic, weak, readonly, nullable) NSArray *sortedPostFormatNames; +@property (nonatomic, weak, readonly, nullable) NSArray *sortedPostFormats; +@property (nonatomic, weak, readonly, nullable) NSArray *sortedConnections; @property (nonatomic, readonly, nullable) NSArray *sortedRoles; -@property (nonatomic, strong, readonly, nullable) WordPressOrgXMLRPCApi *xmlrpcApi; -@property (nonatomic, strong, readonly, nullable) WordPressOrgRestApi *selfHostedSiteRestApi; -@property (nonatomic, weak, readonly, nullable) NSString *version; -@property (nonatomic, strong, readonly, nullable) NSString *authToken; -@property (nonatomic, strong, readonly, nullable) NSSet *allowedFileTypes; +@property (nonatomic, strong, readonly, nullable) WordPressOrgXMLRPCApi *xmlrpcApi; +@property (nonatomic, strong, readonly, nullable) WordPressOrgRestApi *selfHostedSiteRestApi; +@property (nonatomic, weak, readonly, nullable) NSString *version; +@property (nonatomic, strong, readonly, nullable) NSString *authToken; +@property (nonatomic, strong, readonly, nullable) NSSet *allowedFileTypes; @property (nonatomic, copy, readonly, nullable) NSString *usernameForSite; @property (nonatomic, assign, readonly) BOOL canBlaze; diff --git a/WordPress/Classes/Models/Blog/BlogSettings.swift b/WordPress/Classes/Models/Blog/BlogSettings.swift index d07e8368719d..ff1f175d26b8 100644 --- a/WordPress/Classes/Models/Blog/BlogSettings.swift +++ b/WordPress/Classes/Models/Blog/BlogSettings.swift @@ -71,6 +71,7 @@ open class BlogSettings: NSManagedObject { /// Jetpack Setting: lazy load images. /// + @available(*, deprecated) @NSManaged var jetpackLazyLoadImages: Bool // MARK: - Discussion diff --git a/WordPress/Classes/Models/PublicizeService.swift b/WordPress/Classes/Models/PublicizeService.swift index e056aec957f6..d4842e0506a2 100644 --- a/WordPress/Classes/Models/PublicizeService.swift +++ b/WordPress/Classes/Models/PublicizeService.swift @@ -37,6 +37,7 @@ extension PublicizeService { case linkedin case instagram = "instagram-business" case mastodon + case threads case unknown /// Returns the local image for the icon representing the social network. diff --git a/WordPress/Classes/Models/ReaderPost+Swift.swift b/WordPress/Classes/Models/ReaderPost+Swift.swift index c751e9f0f633..bed1ea862934 100644 --- a/WordPress/Classes/Models/ReaderPost+Swift.swift +++ b/WordPress/Classes/Models/ReaderPost+Swift.swift @@ -1,4 +1,5 @@ import Foundation +import WordPressUI extension ReaderPost { diff --git a/WordPress/Classes/Models/ReaderTagTopic.swift b/WordPress/Classes/Models/ReaderTagTopic.swift index 9151484d576d..7bb43246d362 100644 --- a/WordPress/Classes/Models/ReaderTagTopic.swift +++ b/WordPress/Classes/Models/ReaderTagTopic.swift @@ -52,6 +52,10 @@ import Foundation return topic } + var formattedTitle: String { + title.split(separator: "-").map(\.capitalized).joined(separator: " ") + } + /// Convenience method to update the tag's `following` state and also updates `showInMenu`. @objc func toggleFollowing(_ isFollowing: Bool) { following = isFollowing diff --git a/WordPress/Classes/Models/WPAccount+Lookup.swift b/WordPress/Classes/Models/WPAccount+Lookup.swift index 1ba1629f8e73..f64cf6832acd 100644 --- a/WordPress/Classes/Models/WPAccount+Lookup.swift +++ b/WordPress/Classes/Models/WPAccount+Lookup.swift @@ -47,6 +47,7 @@ public extension WPAccount { /// static func lookup(withUUIDString uuidString: String, in context: NSManagedObjectContext) throws -> WPAccount? { let fetchRequest = NSFetchRequest(entityName: WPAccount.entityName()) + fetchRequest.fetchLimit = 1 fetchRequest.predicate = NSPredicate(format: "uuid = %@", uuidString) guard let defaultAccount = try context.fetch(fetchRequest).first else { @@ -70,6 +71,7 @@ public extension WPAccount { /// static func lookup(withUsername username: String, in context: NSManagedObjectContext) throws -> WPAccount? { let fetchRequest = NSFetchRequest(entityName: WPAccount.entityName()) + fetchRequest.fetchLimit = 1 fetchRequest.predicate = NSPredicate(format: "username = [c] %@ || email = [c] %@", username, username) guard let account = try context.fetch(fetchRequest).first else { @@ -88,6 +90,7 @@ public extension WPAccount { /// static func lookup(withUserID userID: Int64, in context: NSManagedObjectContext) throws -> WPAccount? { let fetchRequest = NSFetchRequest(entityName: WPAccount.entityName()) + fetchRequest.fetchLimit = 1 fetchRequest.predicate = NSPredicate(format: "userID = %ld", userID) guard let account = try context.fetch(fetchRequest).first else { diff --git a/WordPress/Classes/Networking/MediaHost+AbstractPost.swift b/WordPress/Classes/Networking/MediaHost+AbstractPost.swift deleted file mode 100644 index d9a9b41a3d1f..000000000000 --- a/WordPress/Classes/Networking/MediaHost+AbstractPost.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import WordPressMedia - -/// Extends `MediaRequestAuthenticator.MediaHost` so that we can easily -/// initialize it from a given `AbstractPost`. -/// -extension MediaHost { - enum AbstractPostError: Swift.Error { - case baseInitializerError(error: BlogError) - } - - init(with post: AbstractPost, failure: (AbstractPostError) -> ()) { - self.init( - with: post.blog, - failure: { error in - // We just associate a post with the underlying error for simpler debugging. - failure(AbstractPostError.baseInitializerError(error: error)) - } - ) - } -} diff --git a/WordPress/Classes/Networking/MediaHost+Blog.swift b/WordPress/Classes/Networking/MediaHost+Blog.swift deleted file mode 100644 index a1e1411b0ed9..000000000000 --- a/WordPress/Classes/Networking/MediaHost+Blog.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation -import WordPressMedia - -/// Extends `MediaRequestAuthenticator.MediaHost` so that we can easily -/// initialize it from a given `Blog`. -/// -extension MediaHost { - enum BlogError: Swift.Error { - case baseInitializerError(error: Error) - } - - init(with blog: Blog) { - self.init(with: blog) { error in - // We'll log the error, so we know it's there, but we won't halt execution. - WordPressAppDelegate.crashLogging?.logError(error) - } - } - - init(with blog: Blog, failure: (BlogError) -> ()) { - let isAtomic = blog.isAtomic() - self.init(with: blog, isAtomic: isAtomic, failure: failure) - } - - init(with blog: Blog, isAtomic: Bool, failure: (BlogError) -> ()) { - self.init( - isAccessibleThroughWPCom: blog.isAccessibleThroughWPCom(), - isPrivate: blog.isPrivate(), - isAtomic: isAtomic, - siteID: blog.dotComID?.intValue, - username: blog.usernameForSite, - authToken: blog.authToken, - failure: { error in - // We just associate a blog with the underlying error for simpler debugging. - failure(BlogError.baseInitializerError(error: error)) - } - ) - } -} diff --git a/WordPress/Classes/Networking/MediaHost+ReaderPost.swift b/WordPress/Classes/Networking/MediaHost+Extensions.swift similarity index 61% rename from WordPress/Classes/Networking/MediaHost+ReaderPost.swift rename to WordPress/Classes/Networking/MediaHost+Extensions.swift index 70be952500ec..197fdf34d15d 100644 --- a/WordPress/Classes/Networking/MediaHost+ReaderPost.swift +++ b/WordPress/Classes/Networking/MediaHost+Extensions.swift @@ -1,11 +1,32 @@ import Foundation -import WordPressMedia -/// Extends `MediaRequestAuthenticator.MediaHost` so that we can easily -/// initialize it from a given `Blog`. -/// extension MediaHost { - init(with post: ReaderPost) { + + // MARK: - MediaHost (AbstractPost) + + init(_ post: AbstractPost) { + self.init(post.blog) + } + + // MARK: - MediaHost (Blog) + + init(_ blog: Blog) { + self.init( + isAccessibleThroughWPCom: blog.isAccessibleThroughWPCom(), + isPrivate: blog.isPrivate(), + isAtomic: blog.isAtomic(), + siteID: blog.dotComID?.intValue, + username: blog.usernameForSite, + authToken: blog.authToken, + failure: { error in + WordPressAppDelegate.crashLogging?.logError(error) + } + ) + } + + // MARK: - MediaHost (ReaderPost) + + init(_ post: ReaderPost) { let isAccessibleThroughWPCom = post.isWPCom || post.isJetpack // This is the only way in which we can obtain the username and authToken here. diff --git a/Modules/Sources/WordPressMedia/MediaHost.swift b/WordPress/Classes/Networking/MediaHost.swift similarity index 91% rename from Modules/Sources/WordPressMedia/MediaHost.swift rename to WordPress/Classes/Networking/MediaHost.swift index 66f8afce31f0..3aad15939825 100644 --- a/Modules/Sources/WordPressMedia/MediaHost.swift +++ b/WordPress/Classes/Networking/MediaHost.swift @@ -1,8 +1,9 @@ import Foundation +import AsyncImageKit /// Defines a media host for request authentication purposes. /// -public enum MediaHost: Equatable, Sendable { +public enum MediaHost: Equatable, Sendable, MediaHostProtocol { case publicSite case publicWPComSite case privateSelfHostedSite @@ -90,4 +91,10 @@ public enum MediaHost: Equatable, Sendable { self = .privateAtomicWPComSite(siteID: siteID, username: username, authToken: authToken) } + + // MARK: - MediaHostProtocol + + public func authenticatedRequest(for url: URL) async throws -> URLRequest { + try await MediaRequestAuthenticator().authenticatedRequest(for: url, host: self) + } } diff --git a/WordPress/Classes/Networking/MediaRequestAuthenticator.swift b/WordPress/Classes/Networking/MediaRequestAuthenticator.swift index f616d7c4a9a6..c05ca6b2ab93 100644 --- a/WordPress/Classes/Networking/MediaRequestAuthenticator.swift +++ b/WordPress/Classes/Networking/MediaRequestAuthenticator.swift @@ -1,5 +1,5 @@ import Foundation -import WordPressMedia +import AsyncImageKit fileprivate let photonHost = "i0.wp.com" fileprivate let secureHttpScheme = "https" @@ -18,8 +18,7 @@ extension URL { /// /// This also includes regular and photon URLs. /// -struct MediaRequestAuthenticator: MediaRequestAuthenticatorProtocol { - +struct MediaRequestAuthenticator { /// Errors conditions that this class can find. /// enum Error: Swift.Error { diff --git a/WordPress/Classes/Services/BlogJetpackSettingsService.swift b/WordPress/Classes/Services/BlogJetpackSettingsService.swift index e8a58a197868..f300c8aae2ef 100644 --- a/WordPress/Classes/Services/BlogJetpackSettingsService.swift +++ b/WordPress/Classes/Services/BlogJetpackSettingsService.swift @@ -142,29 +142,6 @@ struct BlogJetpackSettingsService { ) } - func updateJetpackLazyImagesModuleSettingForBlog(_ blog: Blog, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) { - guard let blogSettings = blog.settings else { - failure(nil) - return - } - - let isActive = blogSettings.jetpackLazyLoadImages - updateJetpackModuleActiveSettingForBlog( - blog, - module: BlogJetpackSettingsServiceRemote.Keys.lazyLoadImages, - active: isActive, - success: { - self.coreDataStack.performAndSave({ context in - guard let blogSettingsInContext = Blog.lookup(withObjectID: blog.objectID, in: context)?.settings else { - return - } - blogSettingsInContext.jetpackLazyLoadImages = isActive - }, completion: success, on: .main) - }, - failure: failure - ) - } - func updateJetpackServeImagesFromOurServersModuleSettingForBlog(_ blog: Blog, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) { guard let blogSettings = blog.settings else { failure(nil) @@ -235,7 +212,6 @@ private extension BlogJetpackSettingsService { } func updateJetpackModulesSettings(_ settings: BlogSettings, remoteSettings: RemoteBlogJetpackModulesSettings) { - settings.jetpackLazyLoadImages = remoteSettings.lazyLoadImages settings.jetpackServeImagesFromOurServers = remoteSettings.serveImagesFromOurServers } diff --git a/WordPress/Classes/Services/MediaHelper.swift b/WordPress/Classes/Services/MediaHelper.swift index 1c6ba9132cad..3285fddab168 100644 --- a/WordPress/Classes/Services/MediaHelper.swift +++ b/WordPress/Classes/Services/MediaHelper.swift @@ -1,5 +1,5 @@ import UIKit -import WordPressMedia +import AsyncImageKit class MediaHelper: NSObject { @@ -82,7 +82,7 @@ extension Media { return configuration }()) let authenticator = MediaRequestAuthenticator() - let host = MediaHost(with: blog) + let host = MediaHost(blog) let temporaryDirectory = Media.remoteDataTemporaryDirectoryURL var output: [URL] = [] diff --git a/WordPress/Classes/Services/MediaImageService.swift b/WordPress/Classes/Services/MediaImageService.swift index e80679b40bd0..292ede942201 100644 --- a/WordPress/Classes/Services/MediaImageService.swift +++ b/WordPress/Classes/Services/MediaImageService.swift @@ -1,7 +1,7 @@ import UIKit import CoreData import WordPressShared -import WordPressMedia +import AsyncImageKit /// A service for retrieval and caching of thumbnails for ``Media`` objects. final class MediaImageService { @@ -110,7 +110,7 @@ final class MediaImageService { } return try? await coreDataStack.performQuery { context in let blog = try context.existingObject(with: media.blogID) - return RemoteImageInfo(imageURL: remoteURL, host: MediaHost(with: blog)) + return RemoteImageInfo(imageURL: remoteURL, host: MediaHost(blog)) } } @@ -266,7 +266,7 @@ final class MediaImageService { return try? await coreDataStack.performQuery { context in let blog = try context.existingObject(with: media.blogID) guard let imageURL = media.getRemoteThumbnailURL(targetSize: targetSize, blog: blog) else { return nil } - return RemoteImageInfo(imageURL: imageURL, host: MediaHost(with: blog)) + return RemoteImageInfo(imageURL: imageURL, host: MediaHost(blog)) } } diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index 00953e877eb0..61d633c4abde 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -18,7 +18,7 @@ class PostCoordinator: NSObject { case maximumRetryTimeIntervalReached var errorDescription: String? { - Strings.genericErrorTitle + SharedStrings.Error.generic } var errorUserInfo: [String: Any] { @@ -177,20 +177,20 @@ class PostCoordinator: NSObject { wpAssertionFailure("Failed to show an error alert") return } - let alert = UIAlertController(title: Strings.genericErrorTitle, message: error.localizedDescription, preferredStyle: .alert) + let alert = UIAlertController(title: SharedStrings.Error.generic, message: error.localizedDescription, preferredStyle: .alert) if let error = error as? PostRepository.PostSaveError { switch error { case .conflict(let latest): - alert.addDefaultActionWithTitle(Strings.buttonOK) { [weak self] _ in + alert.addDefaultActionWithTitle(SharedStrings.Button.ok) { [weak self] _ in self?.showResolveConflictView(post: post, remoteRevision: latest, source: .editor) } case .deleted: - alert.addDefaultActionWithTitle(Strings.buttonOK) { [weak self] _ in + alert.addDefaultActionWithTitle(SharedStrings.Button.ok) { [weak self] _ in self?.handlePermanentlyDeleted(post) } } } else { - alert.addDefaultActionWithTitle(Strings.buttonOK, handler: nil) + alert.addDefaultActionWithTitle(SharedStrings.Button.ok, handler: nil) } topViewController.present(alert, animated: true) } @@ -945,8 +945,3 @@ private extension NSManagedObjectID { .trimmingCharacters(in: CharacterSet(charactersIn: "/>")) } } - -private enum Strings { - static let genericErrorTitle = NSLocalizedString("postNotice.errorTitle", value: "An error occured", comment: "A generic error message title") - static let buttonOK = NSLocalizedString("postNotice.ok", value: "OK", comment: "Button OK") -} diff --git a/WordPress/Classes/Stores/StatsPeriodStore.swift b/WordPress/Classes/Stores/StatsPeriodStore.swift index ab7937953065..8686f4740f14 100644 --- a/WordPress/Classes/Stores/StatsPeriodStore.swift +++ b/WordPress/Classes/Stores/StatsPeriodStore.swift @@ -895,12 +895,24 @@ private extension StatsPeriodStore { } } - private func receivedPostsAndPages(_ postsAndPages: StatsTopPostsTimeIntervalData?, _ error: Error?) { + private func receivedPostsAndPages(_ data: StatsTopPostsTimeIntervalData?, _ error: Error?) { transaction { state in state.topPostsAndPagesStatus = error != nil ? .error : .success - if postsAndPages != nil { - state.topPostsAndPages = postsAndPages + if let data { + let sortedTopPosts = data.topPosts.sorted { lhs, rhs in + if lhs.viewsCount == rhs.viewsCount { + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + return lhs.viewsCount > rhs.viewsCount + } + state.topPostsAndPages = StatsTopPostsTimeIntervalData( + period: data.period, + periodEndDate: data.periodEndDate, + topPosts: sortedTopPosts, + totalViewsCount: data.totalViewsCount, + otherViewsCount: data.otherViewsCount + ) } } } diff --git a/WordPress/Classes/System/WordPress-Bridging-Header.h b/WordPress/Classes/System/WordPress-Bridging-Header.h index 53304de77aec..01a375ecb212 100644 --- a/WordPress/Classes/System/WordPress-Bridging-Header.h +++ b/WordPress/Classes/System/WordPress-Bridging-Header.h @@ -41,7 +41,6 @@ #import "PostServiceOptions.h" #import "PostSettingsViewController.h" #import "PostSettingsViewController_Internal.h" -#import "WPProgressTableViewCell.h" #import "PostTag.h" #import "PostTagService.h" @@ -85,7 +84,6 @@ #import "WPAuthTokenIssueSolver.h" #import "WPUploadStatusButton.h" #import "WPError.h" -#import "WPImageViewController.h" #import "WPStyleGuide+Pages.h" #import "WPStyleGuide+WebView.h" #import "WPTableViewHandler.h" diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index fd78c601c94d..aba96ac78543 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -5,7 +5,7 @@ import AutomatticTracks import AutomatticEncryptedLogs import WordPressAuthenticator import WordPressShared -import WordPressMedia +import AsyncImageKit import AutomatticAbout import UIDeviceIdentifier import WordPressUI diff --git a/WordPress/Classes/Users/Views/UserDetailsView.swift b/WordPress/Classes/Users/Views/UserDetailsView.swift index a1bc81e2113e..2785de5fd482 100644 --- a/WordPress/Classes/Users/Views/UserDetailsView.swift +++ b/WordPress/Classes/Users/Views/UserDetailsView.swift @@ -240,13 +240,6 @@ struct UserDetailsView: View { value: "There was an error deleting the user.", comment: "The message in the alert that appears when deleting a user" ) - - static let deleteUserErrorAlertOkButton = NSLocalizedString( - "userDetails.alert.deleteUserErrorAlertOkButton", - value: "OK", - comment: "The title of the OK button in the alert that appears when deleting a user" - ) - } } @@ -293,7 +286,7 @@ private extension View { isPresented: view.$presentDeleteUserError, presenting: view.deleteUserViewModel.error, actions: { _ in - Button(Strings.deleteUserErrorAlertOkButton) { + Button(SharedStrings.Button.ok) { view.presentDeleteUserError = false } }, diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index 5fece8a12885..0e1f26a7c723 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -5,7 +5,6 @@ import Foundation case createSheetShown case createSheetActionTapped - case createAnnouncementModalShown // Media Editor case mediaEditorShown @@ -117,6 +116,7 @@ import Foundation case readerCommentTextCopied case readerPostContextMenuButtonTapped case readerAddSiteToFavoritesTapped + case readerButtonScrollToTopTapped // Stats - Empty Stats nudges case statsPublicizeNudgeShown @@ -625,8 +625,6 @@ import Foundation return "create_sheet_shown" case .createSheetActionTapped: return "create_sheet_action_tapped" - case .createAnnouncementModalShown: - return "create_announcement_modal_shown" // Media Editor case .mediaEditorShown: return "media_editor_shown" @@ -819,6 +817,8 @@ import Foundation return "reader_post_context_menu_button_tapped" case .readerAddSiteToFavoritesTapped: return "reader_add_site_to_favorites_tapped" + case .readerButtonScrollToTopTapped: + return "reader_button_scroll_to_top_tapped" // Stats - Empty Stats nudges case .statsPublicizeNudgeShown: diff --git a/WordPress/Classes/Utility/App Configuration/AppColor.swift b/WordPress/Classes/Utility/App Configuration/AppColor.swift index d55f47c19ad4..5a4a1d3679ec 100644 --- a/WordPress/Classes/Utility/App Configuration/AppColor.swift +++ b/WordPress/Classes/Utility/App Configuration/AppColor.swift @@ -88,13 +88,7 @@ struct UIAppColor { #if IS_JETPACK static let tint = UIColor.label - static let brand = UIColor(light: CSColor.JetpackGreen.shade(.shade40), dark: CSColor.JetpackGreen.shade(.shade30)) - - static func brand(_ shade: ColorStudioShade) -> UIColor { - CSColor.JetpackGreen.shade(shade) - } - - static let primary = CSColor.JetpackGreen.base + static let primary = UIColor(light: CSColor.JetpackGreen.shade(.shade40), dark: CSColor.JetpackGreen.shade(.shade30)) static func primary(_ shade: ColorStudioShade) -> UIColor { CSColor.JetpackGreen.shade(shade) @@ -102,15 +96,9 @@ struct UIAppColor { #endif #if IS_WORDPRESS - static let tint = brand - - static let brand = CSColor.WordPressBlue.base - - static func brand(_ shade: ColorStudioShade) -> UIColor { - CSColor.WordPressBlue.shade(shade) - } + static let tint = primary - static let primary = CSColor.Blue.base + static let primary = UIColor(light: CSColor.Blue.base, dark: primary(.shade40)) static func primary(_ shade: ColorStudioShade) -> UIColor { CSColor.Blue.shade(shade) @@ -142,10 +130,10 @@ struct UIAppColor { static let prologueBackground = UIColor(light: blue(.shade0), dark: .systemBackground) - static let switchStyle: SwitchToggleStyle = SwitchToggleStyle(tint: Color(UIAppColor.brand)) + static let switchStyle: SwitchToggleStyle = SwitchToggleStyle(tint: Color(UIAppColor.primary)) } struct AppColor { static let tint = Color(UIAppColor.tint) - static let brand = Color(UIAppColor.brand) + static let primary = Color(UIAppColor.primary) } diff --git a/WordPress/Classes/Utility/ContentCoordinator.swift b/WordPress/Classes/Utility/ContentCoordinator.swift index 27d63494accd..8da84d65c2a8 100644 --- a/WordPress/Classes/Utility/ContentCoordinator.swift +++ b/WordPress/Classes/Utility/ContentCoordinator.swift @@ -142,10 +142,9 @@ struct DefaultContentCoordinator: ContentCoordinator { } func displayFullscreenImage(_ image: UIImage) { - let imageViewController = WPImageViewController(image: image) - imageViewController.modalTransitionStyle = .crossDissolve - imageViewController.modalPresentationStyle = .fullScreen - controller?.present(imageViewController, animated: true) + let lightboxVC = LightboxViewController(.image(image)) + lightboxVC.configureZoomTransition() + controller?.present(lightboxVC, animated: true) } func displayPlugin(withSlug pluginSlug: String, on siteSlug: String) throws { diff --git a/WordPress/Classes/Utility/Image Dimension Parser/ImageDimensionFetcher.swift b/WordPress/Classes/Utility/Image Dimension Parser/ImageDimensionFetcher.swift deleted file mode 100644 index 28d4a693619d..000000000000 --- a/WordPress/Classes/Utility/Image Dimension Parser/ImageDimensionFetcher.swift +++ /dev/null @@ -1,87 +0,0 @@ -import UIKit - -class ImageDimensionsFetcher: NSObject, URLSessionDataDelegate { - // Helpful typealiases for the closures - public typealias CompletionHandler = (ImageDimensionFormat, CGSize?) -> Void - public typealias ErrorHandler = (Error?) -> Void - - let completionHandler: CompletionHandler - let errorHandler: ErrorHandler? - - // Internal use properties - private let request: URLRequest - private var task: URLSessionDataTask? = nil - private let parser: ImageDimensionParser - private var session: URLSession? = nil - - deinit { - cancel() - } - - init(request: URLRequest, - success: @escaping CompletionHandler, - error: ErrorHandler? = nil, - imageParser: ImageDimensionParser = ImageDimensionParser()) { - self.request = request - self.completionHandler = success - self.errorHandler = error - self.parser = imageParser - - super.init() - } - - /// Starts the calculation process - func start() { - let config = URLSessionConfiguration.default - let session = URLSession(configuration: config, delegate: self, delegateQueue: nil) - let task = session.dataTask(with: request) - task.resume() - - self.task = task - self.session = session - } - - func cancel() { - session?.invalidateAndCancel() - task?.cancel() - } - - // MARK: - URLSessionDelegate - public func urlSession(_ session: URLSession, task dataTask: URLSessionTask, didCompleteWithError error: Error?) { - // Don't trigger an error if we cancelled the task - if let error, (error as NSError).code == NSURLErrorCancelled { - return - } - - self.errorHandler?(error) - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - // Add the downloaded data to the parser - parser.append(bytes: data) - - // Wait for the format to be detected - guard let format = parser.format else { - return - } - - // Check if the format is unsupported - guard format != .unsupported else { - completionHandler(format, nil) - - // We can't parse unsupported images, cancel the download - cancel() - return - } - - // Wait for the image size - guard let size = parser.imageSize else { - return - } - - completionHandler(format, size) - - // The image size has been calculated, stop downloading - cancel() - } -} diff --git a/WordPress/Classes/Utility/Image Dimension Parser/ImageDimensionParser.swift b/WordPress/Classes/Utility/Image Dimension Parser/ImageDimensionParser.swift deleted file mode 100644 index de2726fe5a58..000000000000 --- a/WordPress/Classes/Utility/Image Dimension Parser/ImageDimensionParser.swift +++ /dev/null @@ -1,268 +0,0 @@ -import UIKit - -class ImageDimensionParser { - private(set) var format: ImageDimensionFormat? - private(set) var imageSize: CGSize? = nil - - private var data: Data - - init(with data: Data = Data()) { - self.data = data - - parse() - } - - public func append(bytes: Data) { - data.append(contentsOf: bytes) - - parse() - } - - private func parse() { - guard - let format = ImageDimensionFormat(with: data) - else { - return - } - - self.format = format - imageSize = dimensions(with: data) - - guard imageSize != nil else { - return - } - } - - // MARK: - Dimension Calculating - private func dimensions(with data: Data) -> CGSize? { - switch format { - case .png: return pngSize(with: data) - case .gif: return gifSize(with: data) - case .jpeg: return jpegSize(with: data) - - default: return nil - } - } - - // MARK: - PNG Parsing - private func pngSize(with data: Data) -> CGSize? { - // Bail out if the data size is too small to read the header - let chunkSize = PNGConstants.chunkSize - let ihdrStart = PNGConstants.headerSize + chunkSize - - // The min length needed to read the width / height - let minLength = ihdrStart + chunkSize * 3 - - guard data.count >= minLength else { - return nil - } - - // Validate the header to make sure the width/height is in the correct spot - guard data.subdata(start: ihdrStart, length: chunkSize) == PNGConstants.IHDR else { - return nil - } - - // Width is immediately after the IHDR header - let widthOffset = ihdrStart + chunkSize - - // Height is after the width - let heightOffset = widthOffset + chunkSize - - // Height and width are stored as 32 bit ints - // http://www.libpng.org/pub/png/spec/1.0/PNG-Chunks.html - // ^ The maximum for each is (2^31)-1 in order to accommodate languages that have difficulty with unsigned 4-byte values. - let width = CFSwapInt32(data[widthOffset, chunkSize] as UInt32) - let height = CFSwapInt32(data[heightOffset, chunkSize] as UInt32) - - return CGSize(width: Int(width), height: Int(height)) - } - - private struct PNGConstants { - // PNG header size is 8 bytes - static let headerSize = 8 - - // PNG is broken up into 4 byte chunks, except for the header - static let chunkSize = 4 - - // IHDR header: // https://www.w3.org/TR/PNG/#11IHDR - static let IHDR = Data([0x49, 0x48, 0x44, 0x52]) - } - - // MARK: - GIF Parsing - private func gifSize(with data: Data) -> CGSize? { - // Bail out if the data size is too small to read the header - let valueSize = GIFConstants.valueSize - let headerSize = GIFConstants.headerSize - - // Min length we need to read is the header size + 4 bytes - let minLength = headerSize + valueSize * 3 - - guard data.count >= minLength else { - return nil - } - - // The width appears directly after the header, and the height after that. - let widthOffset = headerSize - let heightOffset = widthOffset - - // Reads the "logical screen descriptor" which appears after the GIF header block - let width: UInt16 = data[widthOffset, valueSize] - let height: UInt16 = data[heightOffset, valueSize] - - return CGSize(width: Int(width), height: Int(height)) - } - - private struct GIFConstants { - // http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp - - // The GIF header size is 6 bytes - static let headerSize = 6 - - // The height and width are stored as 2 byte values - static let valueSize = 2 - } - - // MARK: - JPEG Parsing - private struct JPEGConstants { - static let blockSize: UInt16 = 256 - - // 16 bytes skips the header and the first block - static let minDataCount = 16 - - static let valueSize = 2 - static let heightOffset = 5 - - // JFIF{NULL} - static let jfifHeader = Data([0x4A, 0x46, 0x49, 0x46, 0x00]) - } - - private func jpegSize(with data: Data) -> CGSize? { - // Bail out if the data size is too small to read the header - guard data.count > JPEGConstants.minDataCount else { - return nil - } - - // Adapted from: - // - https://web.archive.org/web/20131016210645/http://www.64lines.com/jpeg-width-height - - var i = JPEGConstants.jfifHeader.count - 1 - - let blockSize: UInt16 = JPEGConstants.blockSize - - // Retrieve the block length of the first block since the first block will not contain the size of file - var block_length = UInt16(data[i]) * blockSize + UInt16(data[i+1]) - - while i < data.count { - i += Int(block_length) - - // Protect again out of bounds issues - // 10 = the max size we need to read all the values from below - if i + 10 >= data.count { - return nil - } - - // Check that we are truly at the start of another block - if data[i] != 0xFF { - return nil - } - - // SOFn marker - let marker = data[i+1] - - let isValidMarker = (marker >= 0xC0 && marker <= 0xC3) || - (marker >= 0xC5 && marker <= 0xC7) || - (marker >= 0xC9 && marker <= 0xCB) || - (marker >= 0xCD && marker <= 0xCF) - - if isValidMarker { - // "Start of frame" marker which contains the file size - let valueSize = JPEGConstants.valueSize - let heightOffset = i + JPEGConstants.heightOffset - let widthOffset = heightOffset + valueSize - - let height = CFSwapInt16(data[heightOffset, valueSize] as UInt16) - let width = CFSwapInt16(data[widthOffset, valueSize] as UInt16) - - return CGSize(width: Int(width), height: Int(height)) - } - - // Go to the next block - i += 2 // Skip the block marker - block_length = UInt16(data[i]) * blockSize + UInt16(data[i+1]) - } - - return nil - } -} - -// MARK: - ImageFormat -enum ImageDimensionFormat { - // WordPress supported image formats: - // https://wordpress.com/support/images/ - // https://codex.wordpress.org/Uploading_Files - case jpeg - case png - case gif - case unsupported - - init?(with data: Data) { - if data.headerIsEqual(to: FileMarker.jpeg) { - self = .jpeg - } - else if data.headerIsEqual(to: FileMarker.gif) { - self = .gif - } - else if data.headerIsEqual(to: FileMarker.png) { - self = .png - } - else if data.count < FileMarker.png.count { - return nil - } - else { - self = .unsupported - } - } - - // File type markers denote the type of image in the first few bytes of the file - private struct FileMarker { - // https://en.wikipedia.org/wiki/JPEG_Network_Graphics - static let png = Data([0x89, 0x50, 0x4E, 0x47]) - - // https://en.wikipedia.org/wiki/JPEG_File_Interchange_Format - // FFD8 = SOI, APP0 marker - static let jpeg = Data([0xFF, 0xD8, 0xFF]) - - // https://en.wikipedia.org/wiki/GIF - static let gif = Data([0x47, 0x49, 0x46, 0x38]) //GIF8 - } -} - -// MARK: - Private: Extensions -private extension Data { - func headerData(with length: Int) -> Data { - return subdata(start: 0, length: length) - } - - func headerIsEqual(to value: Data) -> Bool { - // Prevent any out of bounds issues - if count < value.count { - return false - } - - let header = headerData(with: value.count) - - return header == value - } - - func subdata(start: Int, length: Int) -> Data { - return subdata(in: start ..< start + length) - } - - subscript(range: Range) -> UInt16 { - return subdata(in: range).withUnsafeBytes { $0.load(as: UInt16.self) } - } - - subscript(start: Int, length: Int) -> T { - return self[start.. Bool -} - -extension GIFPlaybackStrategy { - func verifyDataSize(_ data: Data) -> Bool { - guard data.count <= maxSize else { - DDLogDebug("⚠️ Maximum GIF data size exceeded \(maxSize) with \(data.count)") - return false - } - return true - } -} -// This is good for thumbnail GIFs used in a collection view -class TinyGIFPlaybackStrategy: GIFPlaybackStrategy { - var maxSize = 2_000_000 // in MB - var frameBufferCount = 5 - var gifStrategy: GIFStrategy = .tinyGIFs -} - -class SmallGIFPlaybackStrategy: GIFPlaybackStrategy { - var maxSize = 8_000_000 // in MB - var frameBufferCount = 50 - var gifStrategy: GIFStrategy = .smallGIFs -} - -class MediumGIFPlaybackStrategy: GIFPlaybackStrategy { - var maxSize = 20_000_000 // in MB - var frameBufferCount = 50 - var gifStrategy: GIFStrategy = .mediumGIFs -} - -class LargeGIFPlaybackStrategy: GIFPlaybackStrategy { - var maxSize = 50_000_000 // in MB - var frameBufferCount = 50 - var gifStrategy: GIFStrategy = .largeGIFs -} diff --git a/WordPress/Classes/Utility/Media/ImageDownloader+Extensions.swift b/WordPress/Classes/Utility/Media/ImageDownloader+Extensions.swift index a35dadacf692..c592ed942089 100644 --- a/WordPress/Classes/Utility/Media/ImageDownloader+Extensions.swift +++ b/WordPress/Classes/Utility/Media/ImageDownloader+Extensions.swift @@ -1,9 +1,5 @@ import Foundation -import WordPressMedia - -extension ImageDownloader { - nonisolated static let shared = ImageDownloader(authenticator: MediaRequestAuthenticator()) -} +import AsyncImageKit // MARK: - ImageDownloader (Closures) diff --git a/WordPress/Classes/Utility/Media/ImageDownloader+Gravatar.swift b/WordPress/Classes/Utility/Media/ImageDownloader+Gravatar.swift index af43bc1eb757..57c6ed5991a2 100644 --- a/WordPress/Classes/Utility/Media/ImageDownloader+Gravatar.swift +++ b/WordPress/Classes/Utility/Media/ImageDownloader+Gravatar.swift @@ -1,9 +1,9 @@ -import Foundation +import UIKit import WordPressUI import Gravatar -import WordPressMedia +import AsyncImageKit -extension WordPressMedia.ImageDownloader { +extension AsyncImageKit.ImageDownloader { nonisolated func downloadGravatarImage(with email: String, forceRefresh: Bool = false, completion: @escaping (UIImage?) -> Void) { diff --git a/WordPress/Classes/Utility/Media/ImageLoader.swift b/WordPress/Classes/Utility/Media/ImageLoader.swift deleted file mode 100644 index 09c65e8e208a..000000000000 --- a/WordPress/Classes/Utility/Media/ImageLoader.swift +++ /dev/null @@ -1,307 +0,0 @@ -import MobileCoreServices -import AlamofireImage -import AutomatticTracks -import WordPressShared -import WordPressMedia - -/// Class used together with `CachedAnimatedImageView` to facilitate the loading of both -/// still images and animated gifs. -/// -/// - warning: Deprecated, please use `AsyncImageView` or `.wp` extensions for `UIImageView`. -@objc class ImageLoader: NSObject { - typealias ImageLoaderSuccessBlock = () -> Void - typealias ImageLoaderFailureBlock = (Error?) -> Void - - // MARK: Public Fields - - public var photonQuality: UInt { - get { - return selectedPhotonQuality - } - set(newPhotonQuality) { - selectedPhotonQuality = min(max(newPhotonQuality, Constants.minPhotonQuality), Constants.maxPhotonQuality) - } - } - - // MARK: - Image Dimensions Support - typealias ImageLoaderDimensionsBlock = (ImageDimensionFormat, CGSize) -> Void - - /// Called if the imageLoader is able to determine the image format, and dimensions - /// for the image prior to it being downloaded. - /// Note: Set the property prior to calling any load method - public var imageDimensionsHandler: ImageLoaderDimensionsBlock? - private var imageDimensionsFetcher: ImageDimensionsFetcher? = nil - - // MARK: Private Fields - - private unowned let imageView: CachedAnimatedImageView - private let loadingIndicator: ActivityIndicatorType - - private var successHandler: ImageLoaderSuccessBlock? - private var errorHandler: ImageLoaderFailureBlock? - private var placeholder: UIImage? - private var selectedPhotonQuality: UInt = Constants.defaultPhotonQuality - - @objc init(imageView: CachedAnimatedImageView, gifStrategy: GIFStrategy = .mediumGIFs) { - self.imageView = imageView - imageView.gifStrategy = gifStrategy - - let loadingIndicator = CircularProgressView(style: .primary) - loadingIndicator.backgroundColor = .clear - self.loadingIndicator = loadingIndicator - - super.init() - - imageView.addLoadingIndicator(self.loadingIndicator, style: .fullView) - } - - /// Removes the gif animation and prevents it from animate again. - /// Call this in a table/collection cell's `prepareForReuse()`. - /// - @objc func prepareForReuse() { - imageView.prepForReuse() - } - - /// Load an image from a specific post, using the given URL. Supports animated images (gifs) as well. - /// - /// - Parameters: - /// - url: The URL to load the image from. - /// - host: The `MediaHost` of the image. - /// - size: The preferred size of the image to load. - /// - func loadImage(with url: URL, from host: MediaHost, preferredSize size: CGSize = .zero) { - if url.isFileURL { - downloadImage(from: url) - } else if url.isGif { - loadGif(with: url, from: host, preferredSize: size) - } else { - imageView.clean() - loadStaticImage(with: url, from: host, preferredSize: size) - } - } - - @objc(loadImageWithURL:fromPost:preferredSize:placeholder:success:error:) - func loadImage(with url: URL, from post: AbstractPost, preferredSize size: CGSize = .zero, placeholder: UIImage?, success: ImageLoaderSuccessBlock?, error: ImageLoaderFailureBlock?) { - - let host = MediaHost(with: post, failure: { error in - WordPressAppDelegate.crashLogging?.logError(error) - }) - - loadImage(with: url, from: host, preferredSize: size, placeholder: placeholder, success: success, error: error) - } - - @objc(loadImageWithURL:fromReaderPost:preferredSize:placeholder:success:error:) - func loadImage(with url: URL, from readerPost: ReaderPost, preferredSize size: CGSize = .zero, placeholder: UIImage?, success: ImageLoaderSuccessBlock?, error: ImageLoaderFailureBlock?) { - - let host = MediaHost(with: readerPost) - loadImage(with: url, from: host, preferredSize: size, placeholder: placeholder, success: success, error: error) - } - - /// Load an image from a specific post, using the given URL. Supports animated images (gifs) as well. - /// - /// - Parameters: - /// - url: The URL to load the image from. - /// - host: The host of the image. - /// - size: The preferred size of the image to load. You can pass height 0 to set width and preserve aspect ratio. - /// - placeholder: A placeholder to show while the image is loading. - /// - success: A closure to be called if the image was loaded successfully. - /// - error: A closure to be called if there was an error loading the image. - func loadImage(with url: URL, from host: MediaHost, preferredSize size: CGSize = .zero, placeholder: UIImage?, success: ImageLoaderSuccessBlock?, error: ImageLoaderFailureBlock?) { - - self.placeholder = placeholder - successHandler = success - errorHandler = error - - loadImage(with: url, from: host, preferredSize: size) - } - - // MARK: - Private helpers - - /// Load an animated image from the given URL. - /// - private func loadGif(with url: URL, from host: MediaHost, preferredSize size: CGSize = .zero) { - let mediaAuthenticator = MediaRequestAuthenticator() - mediaAuthenticator.authenticatedRequest( - for: url, - from: host, - onComplete: { request in - self.downloadGif(from: request) - }, - onFailure: { error in - WordPressAppDelegate.crashLogging?.logError(error) - self.callErrorHandler(with: error) - }) - } - - /// Load a static image from the given URL. - /// - private func loadStaticImage(with url: URL, from host: MediaHost, preferredSize size: CGSize = .zero) { - let finalURL: URL - - switch host { - case .publicSite: fallthrough - case .privateSelfHostedSite: - finalURL = url - case .publicWPComSite: fallthrough - case .privateAtomicWPComSite: - finalURL = photonUrl(with: url, preferredSize: size) - case .privateWPComSite: - finalURL = privateImageURL(with: url, from: host, preferredSize: size) - } - - let mediaRequestAuthenticator = MediaRequestAuthenticator() - - mediaRequestAuthenticator.authenticatedRequest(for: finalURL, from: host, onComplete: { request in - self.downloadImage(from: request) - }) { error in - WordPressAppDelegate.crashLogging?.logError(error) - self.callErrorHandler(with: error) - } - } - - /// Constructs the URL for an image from a private post hosted in WPCom. - /// - private func privateImageURL(with url: URL, from host: MediaHost, preferredSize size: CGSize) -> URL { - let scale = UIScreen.main.scale - let scaledSize = CGSize(width: size.width * scale, height: size.height * scale) - let scaledURL = WPImageURLHelper.imageURLWithSize(scaledSize, forImageURL: url) - - return scaledURL - } - - /// Gets the photon URL with the specified size, or returns the passed `URL` - /// - private func photonUrl(with url: URL, preferredSize size: CGSize) -> URL { - guard let photonURL = getPhotonUrl(for: url, size: size) else { - return url - } - - return photonURL - } - - /// Triggers the image dimensions fetcher if the `imageDimensionsHandler` property is set - private func calculateImageDimensionsIfNeeded(from request: URLRequest) { - guard let imageDimensionsHandler else { - return - } - - let fetcher = ImageDimensionsFetcher(request: request, success: { (format, size) in - guard let size, size != .zero else { - return - } - - DispatchQueue.main.async { - imageDimensionsHandler(format, size) - } - }) - - fetcher.start() - - imageDimensionsFetcher = fetcher - } - - /// Stop the image dimension calculation - private func cancelImageDimensionCalculation() { - imageDimensionsFetcher?.cancel() - imageDimensionsFetcher = nil - } - - /// Download the animated image from the given URL Request. - /// - private func downloadGif(from request: URLRequest) { - calculateImageDimensionsIfNeeded(from: request) - - imageView.startLoadingAnimation() - imageView.setAnimatedImage(request, placeholderImage: placeholder, success: { [weak self] in - self?.callSuccessHandler() - }) { [weak self] (error) in - self?.callErrorHandler(with: error) - } - } - - /// Downloads the image from the given URL Request. - /// - private func downloadImage(from request: URLRequest) { - calculateImageDimensionsIfNeeded(from: request) - - imageView.startLoadingAnimation() - imageView.af.setImage(withURLRequest: request, completion: { [weak self] dataResponse in - guard let self else { - return - } - - switch dataResponse.result { - case .success: - self.callSuccessHandler() - case .failure(let error): - self.callErrorHandler(with: error) - } - }) - } - - /// Downloads the image from the given URL. - /// - private func downloadImage(from url: URL) { - let request = URLRequest(url: url) - downloadImage(from: request) - } - - private func callSuccessHandler() { - cancelImageDimensionCalculation() - - imageView.stopLoadingAnimation() - guard successHandler != nil else { - return - } - DispatchQueue.main.async { - self.successHandler?() - } - } - - private func callErrorHandler(with error: Error?) { - if let error, (error as NSError).code == NSURLErrorCancelled { - return - } - - cancelImageDimensionCalculation() - - DispatchQueue.main.async { [weak self] in - guard let self else { - return - } - - if self.imageView.shouldShowLoadingIndicator { - (self.loadingIndicator as? CircularProgressView)?.state = .error - } - - self.errorHandler?(error) - } - } -} - -// MARK: - Loading Media object - -extension ImageLoader { - private func getPhotonUrl(for url: URL, size: CGSize) -> URL? { - var finalSize = size - if url.isGif { - // Photon helper sets the size to load the retina version. We don't want that for gifs - let scale = UIScreen.main.scale - finalSize = CGSize(width: size.width / scale, height: size.height / scale) - } - return PhotonImageURLHelper.photonURL(with: finalSize, - forImageURL: url, - forceResize: true, - imageQuality: selectedPhotonQuality) - } -} - -// MARK: - Constants - -private extension ImageLoader { - enum Constants { - static let minPhotonQuality: UInt = 1 - static let maxPhotonQuality: UInt = 100 - static let defaultPhotonQuality: UInt = 80 - } -} diff --git a/WordPress/Classes/Utility/Media/MediaExternalExporter.swift b/WordPress/Classes/Utility/Media/MediaExternalExporter.swift index 75dfc1bbbe69..62bf11469178 100644 --- a/WordPress/Classes/Utility/Media/MediaExternalExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaExternalExporter.swift @@ -1,5 +1,5 @@ import Foundation -import WordPressMedia +import AsyncImageKit /// Media export handling assets from external sources i.e.: Stock Photos /// @@ -46,24 +46,17 @@ class MediaExternalExporter: MediaExporter { return Progress.discreteCompletedProgress() } - /// Downloads an external GIF file, or uses one from the AnimatedImageCache. - /// private func downloadGif(from url: URL, onCompletion: @escaping OnMediaExport, onError: @escaping OnExportError) -> Progress { - let request = URLRequest(url: url) - let task = AnimatedImageCache.shared.animatedImage(request, placeholderImage: nil, - success: { (data, _) in - self.gifDataDownloaded(data: data, - fromURL: url, - error: nil, - onCompletion: onCompletion, - onError: onError) - }, failure: { error in - if let error { - onError(self.exporterErrorWith(error: error)) + Task { + do { + let options = ImageRequestOptions(isMemoryCacheEnabled: false) + let data = try await ImageDownloader.shared.data(for: ImageRequest(url: url, options: options)) + self.gifDataDownloaded(data: data, fromURL: url, error: nil, onCompletion: onCompletion, onError: onError) + } catch { + onError(ExportError.downloadError(error as NSError)) } - }) - - return task?.progress ?? Progress.discreteCompletedProgress() + } + return Progress.discreteCompletedProgress() } /// Saves downloaded GIF data to the filesystem and exports it. diff --git a/WordPress/Classes/Utility/Media/MemoryCache+Extensions.swift b/WordPress/Classes/Utility/Media/MemoryCache+Extensions.swift index 48e208b35a4e..4123729b19f3 100644 --- a/WordPress/Classes/Utility/Media/MemoryCache+Extensions.swift +++ b/WordPress/Classes/Utility/Media/MemoryCache+Extensions.swift @@ -1,6 +1,5 @@ import UIKit -import WordPressMedia -import AlamofireImage +import AsyncImageKit import WordPressUI extension MemoryCache { @@ -8,13 +7,6 @@ extension MemoryCache { func register() { // WordPressUI WordPressUI.ImageCache.shared = WordpressUICacheAdapter(cache: .shared) - - // AlamofireImage - UIImageView.af.sharedImageDownloader = AlamofireImage.ImageDownloader( - imageCache: AlamofireImageCacheAdapter(cache: .shared) - ) - - // WordPress.AnimatedImageCache uses WordPress.MemoryCache directly } } @@ -29,45 +21,3 @@ private struct WordpressUICacheAdapter: WordPressUI.ImageCaching { cache.getImage(forKey: key) } } - -private struct AlamofireImageCacheAdapter: AlamofireImage.ImageRequestCache { - let cache: MemoryCache - - func image(for request: URLRequest, withIdentifier identifier: String?) -> AlamofireImage.Image? { - image(withIdentifier: cacheKey(for: request, identifier: identifier)) - } - - func add(_ image: AlamofireImage.Image, for request: URLRequest, withIdentifier identifier: String?) { - add(image, withIdentifier: cacheKey(for: request, identifier: identifier)) - } - - func removeImage(for request: URLRequest, withIdentifier identifier: String?) -> Bool { - removeImage(withIdentifier: cacheKey(for: request, identifier: identifier)) - } - - func image(withIdentifier identifier: String) -> AlamofireImage.Image? { - cache.getImage(forKey: identifier) - } - - func add(_ image: AlamofireImage.Image, withIdentifier identifier: String) { - cache.setImage(image, forKey: identifier) - } - - func removeImage(withIdentifier identifier: String) -> Bool { - cache.removeImage(forKey: identifier) - return true - } - - func removeAllImages() -> Bool { - // Do nothing (the app decides when to remove images) - return true - } - - private func cacheKey(for request: URLRequest, identifier: String?) -> String { - var key = request.url?.absoluteString ?? "" - if let identifier { - key += "-\(identifier)" - } - return key - } -} diff --git a/WordPress/Classes/Utility/Notifications/PushAuthenticationManager.swift b/WordPress/Classes/Utility/Notifications/PushAuthenticationManager.swift index 8c03452cf608..f31f09ae2245 100644 --- a/WordPress/Classes/Utility/Notifications/PushAuthenticationManager.swift +++ b/WordPress/Classes/Utility/Notifications/PushAuthenticationManager.swift @@ -128,10 +128,9 @@ private extension PushAuthenticationManager { /// Displays an AlertView indicating that a Login Request has expired. /// func showLoginExpiredAlert() { - let title = NSLocalizedString("Login Request Expired", comment: "Login Request Expired") - let message = NSLocalizedString("The login request has expired. Log in to WordPress.com to try again.", - comment: "WordPress.com Push Authentication Expired message") - let acceptButtonTitle = NSLocalizedString("OK", comment: "OK") + let title = NSLocalizedString("Login Request Expired", comment: "Login Request Expired") + let message = NSLocalizedString("The login request has expired. Log in to WordPress.com to try again.", comment: "WordPress.com Push Authentication Expired message") + let acceptButtonTitle = SharedStrings.Button.ok alertControllerProxy.show(withTitle: title, message: message, @@ -147,9 +146,9 @@ private extension PushAuthenticationManager { /// - completion: A closure that receives a parameter, indicating whether the login attempt was confirmed or not. /// func showLoginVerificationAlert(_ message: String, completion: @escaping ((_ approved: Bool) -> ())) { - let title = NSLocalizedString("Verify Log In", comment: "Push Authentication Alert Title") - let cancelButtonTitle = NSLocalizedString("Ignore", comment: "Ignore action. Verb") - let acceptButtonTitle = NSLocalizedString("Approve", comment: "Approve action. Verb") + let title = NSLocalizedString("Verify Log In", comment: "Push Authentication Alert Title") + let cancelButtonTitle = NSLocalizedString("Ignore", comment: "Ignore action. Verb") + let acceptButtonTitle = NSLocalizedString("Approve", comment: "Approve action. Verb") alertControllerProxy.show(withTitle: title, message: message, diff --git a/WordPress/Classes/Utility/SharedStrings.swift b/WordPress/Classes/Utility/SharedStrings.swift index 628c684947b1..d22a5186b5ec 100644 --- a/WordPress/Classes/Utility/SharedStrings.swift +++ b/WordPress/Classes/Utility/SharedStrings.swift @@ -23,12 +23,18 @@ enum SharedStrings { enum Error { static let generic = NSLocalizedString("shared.error.geneirc", value: "Something went wrong", comment: "A generic error message") + static let refreshFailed = NSLocalizedString("shared.error.failiedToReloadData", value: "Failed to update data", comment: "A generic error title indicating that a screen failed to fetch the latest data") } enum Reader { /// - warning: This is the legacy value. It's not compliant with the new format but has the correct translation for different languages. static let title = NSLocalizedString("Reader", comment: "The accessibility value of the Reader tab.") static let unfollow = NSLocalizedString("reader.button.unfollow", value: "Unfollow", comment: "Reader sidebar button title") + static let subscribe = NSLocalizedString("reader.button.subscribe", value: "Subscribe", comment: "A shared button title for Reader") + static let unsubscribe = NSLocalizedString("reader.button.unsubscribe", value: "Unsubscribe", comment: "A shared button title for Reader") + static let addToFavorites = NSLocalizedString("reader.button.addToFavorites", value: "Add to Favorites", comment: "A shared button title for Reader") + static let notificationSettings = NSLocalizedString("reader.button.notificationSettings", value: "Notification Settings", comment: "A shared button title for Reader") + static let removeFromFavorites = NSLocalizedString("reader.button.removeFromFavorites", value: "Remove from Favorites", comment: "A shared button title for Reader") static let recent = NSLocalizedString("reader.recent.title", value: "Recent", comment: "Used in multiple contexts, usually as a screen title") static let discover = NSLocalizedString("reader.discover.title", value: "Discover", comment: "Used in multiple contexts, usually as a screen title") static let saved = NSLocalizedString("reader.saved.title", value: "Saved", comment: "Used in multiple contexts, usually as a screen title") diff --git a/WordPress/Classes/Utility/Spotlight/SearchManager.swift b/WordPress/Classes/Utility/Spotlight/SearchManager.swift index eeb41704e412..52f20c19f3be 100644 --- a/WordPress/Classes/Utility/Spotlight/SearchManager.swift +++ b/WordPress/Classes/Utility/Spotlight/SearchManager.swift @@ -458,7 +458,7 @@ fileprivate extension SearchManager { let controller = PreviewWebKitViewController(post: apost, source: "spotlight_preview_post") controller.trackOpenEvent() - let navWrapper = LightNavigationController(rootViewController: controller) + let navWrapper = UINavigationController(rootViewController: controller) let rootViewController = RootViewCoordinator.sharedPresenter.rootViewController if rootViewController.traitCollection.userInterfaceIdiom == .pad { navWrapper.modalPresentationStyle = .fullScreen diff --git a/WordPress/Classes/Utility/WPError.m b/WordPress/Classes/Utility/WPError.m index 881591ff5f06..3d2d3e11fccd 100644 --- a/WordPress/Classes/Utility/WPError.m +++ b/WordPress/Classes/Utility/WPError.m @@ -131,15 +131,13 @@ + (void)showAlertWithTitle:(NSString *)title message:(NSString *)message withSup [alertController addAction:action]; // Add the 'Need help' button only if internet is accessible (i.e. if the user can actually get help). - if (showSupport && ReachabilityUtils.isInternetReachable) { + if (showSupport) { NSString *supportText = NSLocalizedString(@"Need Help?", @"'Need help?' button label, links off to the WP for iOS FAQ."); - UIAlertAction *action = [UIAlertAction actionWithTitle:supportText - style:UIAlertActionStyleCancel - handler:^(UIAlertAction * _Nonnull __unused action) { - SupportTableViewController *supportVC = [[SupportTableViewController alloc] init]; - [supportVC showFromTabBar]; - [WPError internalInstance].alertShowing = NO; - }]; + UIAlertAction *action = [UIAlertAction actionWithTitle:supportText style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull __unused action) { + SupportTableViewController *supportVC = [[SupportTableViewController alloc] init]; + [supportVC showFromTabBar]; + [WPError internalInstance].alertShowing = NO; + }]; [alertController addAction:action]; } [alertController presentFromRootViewController]; diff --git a/WordPress/Classes/Utility/WebViewController/WebKitViewController.swift b/WordPress/Classes/Utility/WebViewController/WebKitViewController.swift index 8474a358a790..5604f8c42a36 100644 --- a/WordPress/Classes/Utility/WebViewController/WebKitViewController.swift +++ b/WordPress/Classes/Utility/WebViewController/WebKitViewController.swift @@ -1,5 +1,3 @@ -import Foundation -import Gridicons import UIKit @preconcurrency import WebKit import WordPressShared @@ -39,7 +37,7 @@ class WebKitViewController: UIViewController, WebKitAuthenticatable { let analyticsSource: String? @objc lazy var backButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: UIImage.gridicon(.chevronLeft).imageFlippedForRightToLeftLayoutDirection(), + let button = UIBarButtonItem(image: UIImage(systemName: "chevron.backward"), style: .plain, target: self, action: #selector(goBack)) @@ -47,7 +45,7 @@ class WebKitViewController: UIViewController, WebKitAuthenticatable { return button }() @objc lazy var forwardButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: .gridicon(.chevronRight), + let button = UIBarButtonItem(image: UIImage(systemName: "chevron.forward"), style: .plain, target: self, action: #selector(goForward)) @@ -55,15 +53,15 @@ class WebKitViewController: UIViewController, WebKitAuthenticatable { return button }() @objc lazy var shareButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: .gridicon(.shareiOS), + let button = UIBarButtonItem(image: UIImage(systemName: "square.and.arrow.up"), style: .plain, target: self, action: #selector(share)) - button.title = NSLocalizedString("Share", comment: "Button label to share a web page") + button.title = NSLocalizedString("webKit.button.share", value: "Share", comment: "Button label to share a web page") return button }() @objc lazy var safariButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: .gridicon(.globe), + let button = UIBarButtonItem(image: UIImage(systemName: "safari"), style: .plain, target: self, action: #selector(openInSafari)) @@ -72,12 +70,12 @@ class WebKitViewController: UIViewController, WebKitAuthenticatable { return button }() @objc lazy var refreshButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: .gridicon(.refresh), style: .plain, target: self, action: #selector(WebKitViewController.refresh)) + let button = UIBarButtonItem(image: UIImage(systemName: "arrow.clockwise"), style: .plain, target: self, action: #selector(WebKitViewController.refresh)) button.title = NSLocalizedString("Refresh", comment: "Button label to refres a web page") return button }() @objc lazy var closeButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: .gridicon(.cross), style: .plain, target: self, action: #selector(WebKitViewController.close)) + let button = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .plain, target: self, action: #selector(WebKitViewController.close)) button.title = NSLocalizedString("webKit.button.dismiss", value: "Dismiss", comment: "Verb. Dismiss the web view screen.") return button }() @@ -178,7 +176,7 @@ class WebKitViewController: UIViewController, WebKitAuthenticatable { let stackView = UIStackView(arrangedSubviews: [ progressView, webView - ]) + ]) stackView.axis = .vertical stackView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stackView) @@ -329,6 +327,9 @@ class WebKitViewController: UIViewController, WebKitAuthenticatable { space, safariButton ] + for item in items { + item.tintColor = UIAppColor.tint + } setToolbarItems(items, animated: false) } diff --git a/WordPress/Classes/Utility/ZendeskUtils.swift b/WordPress/Classes/Utility/ZendeskUtils.swift index 546a0a37cd68..2dcbbd89a9f7 100644 --- a/WordPress/Classes/Utility/ZendeskUtils.swift +++ b/WordPress/Classes/Utility/ZendeskUtils.swift @@ -1141,8 +1141,6 @@ private extension ZendeskUtils { struct LocalizedText { static let alertMessageWithName = NSLocalizedString("To continue please enter your email address and name.", comment: "Instructions for alert asking for email and name.") static let alertMessage = NSLocalizedString("Please enter your email address.", comment: "Instructions for alert asking for email.") - static let alertSubmit = NSLocalizedString("OK", comment: "Submit button on prompt for user information.") - static let alertCancel = NSLocalizedString("Cancel", comment: "Cancel prompt for user information.") static let emailPlaceholder = NSLocalizedString("Email", comment: "Email address text field placeholder") static let emailAccessibilityLabel = NSLocalizedString("Email", comment: "Accessibility label for the Email text field.") static let namePlaceholder = NSLocalizedString("Name", comment: "Name text field placeholder") @@ -1192,8 +1190,8 @@ extension ZendeskUtils { optionalIdentity: false, includesName: true, message: LocalizedText.alertMessageWithName, - submit: LocalizedText.alertSubmit, - cancel: LocalizedText.alertCancel + submit: SharedStrings.Button.ok, + cancel: SharedStrings.Button.cancel ) } @@ -1202,8 +1200,8 @@ extension ZendeskUtils { optionalIdentity: false, includesName: false, message: LocalizedText.alertMessage, - submit: LocalizedText.alertSubmit, - cancel: LocalizedText.alertCancel + submit: SharedStrings.Button.ok, + cancel: SharedStrings.Button.cancel ) } } diff --git a/WordPress/Classes/ViewRelated/Aztec/ViewControllers/AztecPostViewController.swift b/WordPress/Classes/ViewRelated/Aztec/ViewControllers/AztecPostViewController.swift index 08b1d51bb244..4bf7296301df 100644 --- a/WordPress/Classes/ViewRelated/Aztec/ViewControllers/AztecPostViewController.swift +++ b/WordPress/Classes/ViewRelated/Aztec/ViewControllers/AztecPostViewController.swift @@ -6,7 +6,7 @@ import Gridicons import WordPressShared import MobileCoreServices import WordPressEditor -import WordPressMedia +import AsyncImageKit import AVKit import AutomatticTracks import MediaEditor @@ -23,7 +23,7 @@ class AztecPostViewController: UIViewController, PostEditor { /// Closure to be executed when the editor gets closed. /// - var onClose: ((_ changesSaved: Bool) -> ())? + var onClose: (() -> ())? /// Verification Prompt Helper /// @@ -2775,7 +2775,7 @@ extension AztecPostViewController { func displayUnableToPlayVideoAlert() { let alertController = UIAlertController(title: MediaUnableToPlayVideoAlert.title, message: MediaUnableToPlayVideoAlert.message, preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Default action"), style: .`default`, handler: nil)) + alertController.addAction(UIAlertAction(title: SharedStrings.Button.ok, style: .default, handler: nil)) present(alertController, animated: true) return } @@ -3088,10 +3088,10 @@ extension AztecPostViewController { } struct Assets { - static let closeButtonModalImage = UIImage.gridicon(.cross) - static let closeButtonRegularImage = UIImage(named: "icon-posts-editor-chevron") - static let defaultMissingImage = UIImage.gridicon(.image) - static let linkPlaceholderImage = UIImage.gridicon(.pages) + static let closeButtonModalImage = UIImage.gridicon(.cross) + static let closeButtonRegularImage = UIImage(systemName: "chevron.backward") + static let defaultMissingImage = UIImage.gridicon(.image) + static let linkPlaceholderImage = UIImage.gridicon(.pages) } struct Constants { diff --git a/WordPress/Classes/ViewRelated/Blaze Campaigns/BlazeCampaignTableViewCell.swift b/WordPress/Classes/ViewRelated/Blaze Campaigns/BlazeCampaignTableViewCell.swift index 10d4b6b32a2b..d32641df8d26 100644 --- a/WordPress/Classes/ViewRelated/Blaze Campaigns/BlazeCampaignTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Blaze Campaigns/BlazeCampaignTableViewCell.swift @@ -1,5 +1,5 @@ import UIKit -import WordPressMedia +import AsyncImageKit final class BlazeCampaignTableViewCell: UITableViewCell, Reusable { @@ -77,7 +77,7 @@ final class BlazeCampaignTableViewCell: UITableViewCell, Reusable { }() private lazy var chevronView: UIImageView = { - let image = UIImage(systemName: "chevron.right")?.imageFlippedForRightToLeftLayoutDirection() + let image = UIImage(systemName: "chevron.forward") let imageView = UIImageView(image: image) imageView.translatesAutoresizingMaskIntoConstraints = false imageView.tintColor = .separator @@ -107,11 +107,8 @@ final class BlazeCampaignTableViewCell: UITableViewCell, Reusable { featuredImageView.prepareForReuse() featuredImageView.isHidden = viewModel.imageURL == nil if let imageURL = viewModel.imageURL { - let host = MediaHost(with: blog, failure: { error in - WordPressAppDelegate.crashLogging?.logError(error) - }) - let preferredSize = CGSize(width: Metrics.featuredImageSize, height: Metrics.featuredImageSize) - .scaled(by: UITraitCollection.current.displayScale) + let host = MediaHost(blog) + let preferredSize = ImageSize(scaling: CGSize(width: Metrics.featuredImageSize, height: Metrics.featuredImageSize), in: self) featuredImageView.setImage(with: imageURL, host: host, size: preferredSize) } diff --git a/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazePostPreviewView.swift b/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazePostPreviewView.swift index 3cdbd57745c3..f88760928a50 100644 --- a/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazePostPreviewView.swift +++ b/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazePostPreviewView.swift @@ -1,5 +1,5 @@ import UIKit -import WordPressMedia +import AsyncImageKit final class BlazePostPreviewView: UIView { @@ -96,13 +96,8 @@ final class BlazePostPreviewView: UIView { if let url = post.featuredImageURL { featuredImageView.isHidden = false - let host = MediaHost(with: post, failure: { error in - // We'll log the error, so we know it's there, but we won't halt execution. - WordPressAppDelegate.crashLogging?.logError(error) - }) - let preferredSize = CGSize(width: featuredImageView.frame.width, height: featuredImageView.frame.height) - .scaled(by: UITraitCollection.current.displayScale) - featuredImageView.setImage(with: url, host: host, size: preferredSize) + let targetSize = ImageSize(scaling: featuredImageView.frame.size, in: self) + featuredImageView.setImage(with: url, host: MediaHost(post), size: targetSize) } else { featuredImageView.isHidden = true diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Blaze/DashboardBlazeCampaignView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Blaze/DashboardBlazeCampaignView.swift index 39c61d3e232d..6ee6f36d99c2 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Blaze/DashboardBlazeCampaignView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Blaze/DashboardBlazeCampaignView.swift @@ -1,7 +1,7 @@ import Foundation import UIKit import WordPressKit -import WordPressMedia +import AsyncImageKit final class DashboardBlazeCampaignView: UIView { private let statusView = BlazeCampaignStatusView() @@ -61,11 +61,8 @@ final class DashboardBlazeCampaignView: UIView { imageView.prepareForReuse() imageView.isHidden = viewModel.imageURL == nil if let imageURL = viewModel.imageURL { - let host = MediaHost(with: blog, failure: { error in - WordPressAppDelegate.crashLogging?.logError(error) - }) - let targetSize = Constants.imageSize - .scaled(by: UITraitCollection.current.displayScale) + let host = MediaHost(blog) + let targetSize = ImageSize(scaling: Constants.imageSize, in: self) imageView.setImage(with: imageURL, host: host, size: targetSize) } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift index a744376c28e6..e7f06e08bf67 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift @@ -271,7 +271,7 @@ class DashboardPromptsCardCell: UICollectionViewCell, Reusable { private lazy var shareButton: UIButton = { let button = UIButton() button.translatesAutoresizingMaskIntoConstraints = false - button.setTitle(Strings.shareButtonTitle, for: .normal) + button.setTitle(SharedStrings.Button.share, for: .normal) button.setTitleColor(WPStyleGuide.BloggingPrompts.buttonTitleColor, for: .normal) button.titleLabel?.font = WPStyleGuide.BloggingPrompts.buttonTitleFont button.titleLabel?.adjustsFontForContentSizeCategory = true @@ -552,7 +552,6 @@ private extension DashboardPromptsCardCell { static let cardFrameTitle = NSLocalizedString("Prompts", comment: "Title label for the Prompts card in My Sites tab.") static let answerButtonTitle = NSLocalizedString("Answer Prompt", comment: "Title for a call-to-action button on the prompts card.") static let answeredLabelTitle = NSLocalizedString("✓ Answered", comment: "Title label that indicates the prompt has been answered.") - static let shareButtonTitle = NSLocalizedString("Share", comment: "Title for a button that allows the user to share their answer to the prompt.") static let answerInfoSingularFormat = NSLocalizedString("%1$d answer", comment: "Singular format string for displaying the number of users " + "that answered the blogging prompt.") static let answerInfoPluralFormat = NSLocalizedString("%1$d answers", comment: "Plural format string for displaying the number of users " diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionCell.swift index 3c4e2427be4b..6d039df7aeea 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionCell.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressUI final class DashboardQuickActionCell: UITableViewCell { private let iconView = UIImageView() @@ -42,8 +43,7 @@ final class DashboardQuickActionCell: UITableViewCell { stackView.isUserInteractionEnabled = false contentView.addSubview(stackView) - stackView.translatesAutoresizingMaskIntoConstraints = false - contentView.pinSubviewToAllEdges(stackView, insets: UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) + stackView.pinEdges(insets: UIEdgeInsets(horizontal: 16, vertical: 12)) } func configure(_ viewModel: DashboardQuickActionItemViewModel) { @@ -65,7 +65,7 @@ final class DashboardQuickActionCell: UITableViewCell { separatorInset = UIEdgeInsets(top: 0, left: bounds.width, bottom: 0, right: 0) } else { let titleLabelFrame = contentView.convert(titleLabel.frame, from: titleLabel.superview) - separatorInset = UIEdgeInsets(top: 0, left: titleLabelFrame.origin.x, bottom: 0, right: 0) + separatorInset = UIEdgeInsets(top: 0, left: traitCollection.layoutDirection == .rightToLeft ? contentView.bounds.width - titleLabelFrame.maxX : titleLabelFrame.origin.x, bottom: 0, right: 0) } } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Me.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Me.swift index e5355eeebacf..af86d97d5858 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Me.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Me.swift @@ -1,6 +1,6 @@ -import Foundation +import UIKit import WordPressUI -import WordPressMedia +import AsyncImageKit import Gravatar extension BlogDetailsViewController { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m index ab41fbca01b8..33b8dc9d72e0 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m @@ -1939,7 +1939,7 @@ - (void)showViewSiteFromSource:(BlogDetailsNavigationSource)source source:@"my_site_view_site" withDeviceModes:true onClose:nil]; - LightNavigationController *navController = [[LightNavigationController alloc] initWithRootViewController:webViewController]; + UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:webViewController]; if (self.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiomPad) { navController.modalPresentationStyle = UIModalPresentationFullScreen; } @@ -1951,11 +1951,6 @@ - (void)showViewSiteFromSource:(BlogDetailsNavigationSource)source - (void)showViewAdmin { - if (![ReachabilityUtils isInternetReachable]) { - [ReachabilityUtils showAlertNoInternetConnection]; - return; - } - [WPAppAnalytics track:WPAnalyticsStatOpenedViewAdmin withBlog:self.blog]; NSString *dashboardUrl; diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/BlogDetailHeaderView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/BlogDetailHeaderView.swift index ebb460f60e46..8e10ee0937e4 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/BlogDetailHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/BlogDetailHeaderView.swift @@ -2,6 +2,7 @@ import Gridicons import UIKit import DesignSystem import SwiftUI +import WordPressUI @objc protocol BlogDetailHeaderViewDelegate { func makeSiteIconMenu() -> UIMenu? @@ -10,6 +11,7 @@ import SwiftUI func siteIconShouldAllowDroppedImages() -> Bool func siteTitleTapped() func siteSwitcherTapped(sourceView: UIView) + func buttonShareSiteTapped() func visitSiteTapped() } @@ -116,6 +118,7 @@ class BlogDetailHeaderView: UIView { self?.delegate?.siteIconReceivedDroppedImage(images.first) } + titleView.subtitleButton.menu = makeSiteLinkMenu() titleView.subtitleButton.addTarget(self, action: #selector(subtitleButtonTapped), for: .touchUpInside) titleView.titleButton.addTarget(self, action: #selector(titleButtonTapped), for: .touchUpInside) @@ -126,6 +129,20 @@ class BlogDetailHeaderView: UIView { setupConstraintsForChildViews() } + private func makeSiteLinkMenu() -> UIMenu { + UIMenu(children: [ + UIAction(title: SharedStrings.Button.share + "…", image: UIImage(systemName: "square.and.arrow.up"), handler: { [weak self] _ in + self?.delegate?.buttonShareSiteTapped() + }), + UIAction(title: Strings.visitSite, image: UIImage(systemName: "safari"), handler: { [weak self] _ in + self?.delegate?.visitSiteTapped() + }), + UIAction(title: SharedStrings.Button.copyLink, image: UIImage(systemName: "doc.on.doc"), handler: { [weak self] _ in + UIPasteboard.general.url = URL(string: (self?.blog?.displayURL ?? "") as String) + }) + ]) + } + // MARK: - Constraints private func setupConstraintsForChildViews() { @@ -203,16 +220,6 @@ extension BlogDetailHeaderView { configuration.contentInsets = isSidebarModeEnabled ? NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 2, trailing: 0) : NSDirectionalEdgeInsets(top: 2, leading: 0, bottom: 1, trailing: 0) configuration.titleLineBreakMode = .byTruncatingTail button.configuration = configuration - - button.menu = UIMenu(children: [ - UIAction(title: Strings.visitSite, image: UIImage(systemName: "safari"), handler: { [weak button] _ in - button?.sendActions(for: .touchUpInside) - }), - UIAction(title: Strings.actionCopyURL, image: UIImage(systemName: "doc.on.doc"), handler: { [weak button] _ in - UIPasteboard.general.url = URL(string: button?.titleLabel?.text ?? "") - }) - ]) - button.accessibilityHint = NSLocalizedString("Tap to view your site", comment: "Accessibility hint for button used to view the user's site") button.translatesAutoresizingMaskIntoConstraints = false return button @@ -354,7 +361,5 @@ private extension String { } private enum Strings { - static let visitSite = NSLocalizedString("blogHeader.actionVisitSite", value: "Visit site", comment: "Context menu button title") - static let actionCopyURL = NSLocalizedString("blogHeader.actionCopyURL", value: "Copy URL", comment: "Context menu button title") - + static let visitSite = NSLocalizedString("blogHeader.actionVisitSite", value: "Visit Site", comment: "Context menu button title") } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/SiteDetailsSiteIconView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/SiteDetailsSiteIconView.swift index b69f148e58c1..d4a4e419c004 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/SiteDetailsSiteIconView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/SiteDetailsSiteIconView.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressUI final class SiteDetailsSiteIconView: UIView { diff --git a/Modules/Sources/WordPressUI/Components/RestApiUpgradePrompt.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/Views/RestApiUpgradePrompt.swift similarity index 93% rename from Modules/Sources/WordPressUI/Components/RestApiUpgradePrompt.swift rename to WordPress/Classes/ViewRelated/Blog/Blog Details/Views/RestApiUpgradePrompt.swift index 7b6160afff0c..a276f9a5fd18 100644 --- a/Modules/Sources/WordPressUI/Components/RestApiUpgradePrompt.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/Views/RestApiUpgradePrompt.swift @@ -1,3 +1,5 @@ +import UIKit +import WordPressUI import SwiftUI public struct RestApiUpgradePrompt: View { @@ -40,9 +42,3 @@ public struct RestApiUpgradePrompt: View { } } } - -#Preview { - RestApiUpgradePrompt(localizedFeatureName: "User Management") { - debugPrint("Tapped Get Started") - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersAnimator.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersAnimator.swift deleted file mode 100644 index 978538eef3dc..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersAnimator.swift +++ /dev/null @@ -1,67 +0,0 @@ -/// A transition animator that moves in the pushed view controller horizontally. -/// Does not handle the pop animation since the BloggingReminders setup flow does not allow to navigate back. -class BloggingRemindersAnimator: NSObject, UIViewControllerAnimatedTransitioning { - - var popStyle = false - - private static let animationDuration: TimeInterval = 0.2 - private static let sourceEndFrameOffset: CGFloat = -60.0 - - func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { - return Self.animationDuration - } - - func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { - - guard !popStyle else { - animatePop(using: transitionContext) - return - } - - guard let sourceViewController = - transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from), - let destinationViewController = - transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else { - return - } - // final position of the destination view - let destinationEndFrame = transitionContext.finalFrame(for: destinationViewController) - // final position of the source view - let sourceEndFrame = transitionContext.initialFrame(for: sourceViewController).offsetBy(dx: Self.sourceEndFrameOffset, dy: .zero) - - // initial position of the destination view - let destinationStartFrame = destinationEndFrame.offsetBy(dx: destinationEndFrame.width, dy: .zero) - destinationViewController.view.frame = destinationStartFrame - - transitionContext.containerView.insertSubview(destinationViewController.view, aboveSubview: sourceViewController.view) - - UIView.animate(withDuration: transitionDuration(using: transitionContext), - animations: { - destinationViewController.view.frame = destinationEndFrame - sourceViewController.view.frame = sourceEndFrame - }, completion: {_ in - transitionContext.completeTransition(true) - }) - } - - func animatePop(using transitionContext: UIViewControllerContextTransitioning) { - guard let sourceViewController = - transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from), - let destinationViewController = - transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else { - return - } - let destinationEndFrame = transitionContext.finalFrame(for: destinationViewController) - let destinationStartFrame = destinationEndFrame.offsetBy(dx: Self.sourceEndFrameOffset, dy: .zero) - destinationViewController.view.frame = destinationStartFrame - transitionContext.containerView.insertSubview(destinationViewController.view, belowSubview: sourceViewController.view) - - UIView.animate(withDuration: transitionDuration(using: transitionContext), - animations: { - destinationViewController.view.frame = destinationEndFrame - sourceViewController.view.transform = sourceViewController.view.transform.translatedBy(x: sourceViewController.view.frame.width, y: 0) - }, completion: {_ in - transitionContext.completeTransition(true) - }) - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlow.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlow.swift deleted file mode 100644 index 582380d5db83..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlow.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation -import WordPressUI - -class BloggingRemindersFlow { - - typealias DismissClosure = () -> Void - - static func present(from viewController: UIViewController, - for blog: Blog, - source: BloggingRemindersTracker.FlowStartSource, - alwaysShow: Bool = true, - delegate: BloggingRemindersFlowDelegate? = nil, - onDismiss: DismissClosure? = nil) { - - guard blog.areBloggingRemindersAllowed() else { - return - } - - guard alwaysShow || !hasShownWeeklyRemindersFlow(for: blog) else { - return - } - - let blogType: BloggingRemindersTracker.BlogType = blog.isHostedAtWPcom ? .wpcom : .selfHosted - - let tracker = BloggingRemindersTracker(blogType: blogType) - tracker.flowStarted(source: source) - - let flowStartViewController = makeStartViewController(for: blog, - tracker: tracker, - source: source, - delegate: delegate) - let navigationController = BloggingRemindersNavigationController( - rootViewController: flowStartViewController, - onDismiss: { - NoticesDispatch.unlock() - onDismiss?() - }) - - let bottomSheet = BottomSheetViewController(childViewController: navigationController, - customHeaderSpacing: 0) - - NoticesDispatch.lock() - bottomSheet.show(from: viewController) - setHasShownWeeklyRemindersFlow(for: blog) - } - - /// if the flow has never been seen, it starts with the intro. Otherwise it starts with the calendar settings - private static func makeStartViewController(for blog: Blog, - tracker: BloggingRemindersTracker, - source: BloggingRemindersTracker.FlowStartSource, - delegate: BloggingRemindersFlowDelegate? = nil) -> UIViewController { - - guard hasShownWeeklyRemindersFlow(for: blog) else { - return BloggingRemindersFlowIntroViewController(for: blog, - tracker: tracker, - source: source, - delegate: delegate) - } - - return (try? BloggingRemindersFlowSettingsViewController(for: blog, tracker: tracker, delegate: delegate)) ?? - BloggingRemindersFlowIntroViewController(for: blog, tracker: tracker, source: source, delegate: delegate) - } - - // MARK: - Weekly reminders flow presentation status - // - // stores a key for each blog in UserDefaults to determine if - // the flow was presented for the given blog. - private static func hasShownWeeklyRemindersFlow(for blog: Blog) -> Bool { - UserPersistentStoreFactory.instance().bool(forKey: weeklyRemindersKey(for: blog)) - } - - static func setHasShownWeeklyRemindersFlow(for blog: Blog) { - UserPersistentStoreFactory.instance().set(true, forKey: weeklyRemindersKey(for: blog)) - } - - private static func weeklyRemindersKey(for blog: Blog) -> String { - // weekly reminders key prefix - let prefix = "blogging-reminder-weekly-" - return prefix + blog.objectID.uriRepresentation().absoluteString - } - - /// By making this private we ensure this can't be instantiated. - /// - private init() { - assertionFailure() - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowIntroViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowIntroViewController.swift deleted file mode 100644 index 76430c083a7f..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowIntroViewController.swift +++ /dev/null @@ -1,223 +0,0 @@ -import UIKit -import WordPressUI - -class BloggingRemindersFlowIntroViewController: UIViewController { - - // MARK: - Subviews - - private let stackView: UIStackView = { - let stackView = UIStackView() - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.spacing = Metrics.stackSpacing - stackView.axis = .vertical - stackView.alignment = .center - stackView.distribution = .equalSpacing - return stackView - }() - - private let imageView: UIImageView = { - let imageView = UIImageView(image: UIImage(named: Images.celebrationImageName)) - imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.tintColor = .systemYellow - return imageView - }() - - private let titleLabel: UILabel = { - let label = UILabel() - label.adjustsFontForContentSizeCategory = true - label.adjustsFontSizeToFitWidth = true - label.font = WPStyleGuide.serifFontForTextStyle(.title1, fontWeight: .semibold) - label.numberOfLines = 2 - label.textAlignment = .center - label.text = TextContent.introTitle - return label - }() - - private let promptLabel: UILabel = { - let label = UILabel() - label.adjustsFontForContentSizeCategory = true - label.adjustsFontSizeToFitWidth = true - label.font = .preferredFont(forTextStyle: .body) - label.numberOfLines = 5 - label.textAlignment = .center - return label - }() - - private lazy var getStartedButton: UIButton = { - let button = FancyButton() - button.isPrimary = true - button.setTitle(TextContent.introButtonTitle, for: .normal) - button.addTarget(self, action: #selector(getStartedTapped), for: .touchUpInside) - return button - }() - - // MARK: - Initializers - - private let blog: Blog - private let tracker: BloggingRemindersTracker - private let source: BloggingRemindersTracker.FlowStartSource - private weak var delegate: BloggingRemindersFlowDelegate? - - private var introDescription: String { - switch source { - case .publishFlow: - return TextContent.postPublishingintroDescription - case .blogSettings, - .notificationSettings, - .statsInsights, - .bloggingPromptsFeatureIntroduction: - return TextContent.siteSettingsIntroDescription - } - } - - init(for blog: Blog, - tracker: BloggingRemindersTracker, - source: BloggingRemindersTracker.FlowStartSource, - delegate: BloggingRemindersFlowDelegate? = nil) { - self.blog = blog - self.tracker = tracker - self.source = source - self.delegate = delegate - - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - // This VC is designed to be instantiated programmatically. If we ever need to initialize this VC - // from a coder, we can implement support for it - but I don't think it's necessary right now. - // - diegoreymendez - fatalError("Use init(tracker:) instead") - } - - // MARK: - View Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .systemBackground - - configureStackView() - configureConstraints() - promptLabel.text = introDescription - } - - override func viewDidAppear(_ animated: Bool) { - tracker.screenShown(.main) - - super.viewDidAppear(animated) - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - // If a parent VC is being dismissed, and this is the last view shown in its navigation controller, we'll assume - // the flow was interrupted. - if isBeingDismissedDirectlyOrByAncestor() && navigationController?.viewControllers.last == self { - tracker.flowDismissed(source: .main) - } - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - calculatePreferredContentSize() - } - - private func calculatePreferredContentSize() { - let size = CGSize(width: view.bounds.width, height: UIView.layoutFittingCompressedSize.height) - preferredContentSize = view.systemLayoutSizeFitting(size) - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - view.setNeedsLayout() - } - - // MARK: - View Configuration - - private func configureStackView() { - view.addSubview(stackView) - stackView.addArrangedSubviews([ - imageView, - titleLabel, - promptLabel, - getStartedButton - ]) - stackView.setCustomSpacing(Metrics.afterPromptSpacing, after: promptLabel) - } - - private func configureConstraints() { - NSLayoutConstraint.activate([ - stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Metrics.edgeMargins.left), - stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.edgeMargins.right), - stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: Metrics.edgeMargins.top), - stackView.bottomAnchor.constraint(lessThanOrEqualTo: view.safeBottomAnchor, constant: -Metrics.edgeMargins.bottom), - - getStartedButton.heightAnchor.constraint(greaterThanOrEqualToConstant: Metrics.getStartedButtonHeight), - getStartedButton.widthAnchor.constraint(equalTo: stackView.widthAnchor), - ]) - } - - @objc private func getStartedTapped() { - tracker.buttonPressed(button: .continue, screen: .main) - - do { - let flowSettingsViewController = try BloggingRemindersFlowSettingsViewController(for: blog, tracker: tracker, delegate: delegate) - - navigationController?.pushViewController(flowSettingsViewController, animated: true) - } catch { - DDLogError("Could not instantiate the blogging reminders settings VC: \(error.localizedDescription)") - dismiss(animated: true, completion: nil) - } - } -} - -extension BloggingRemindersFlowIntroViewController: BloggingRemindersActions { - - @objc private func dismissTapped() { - dismiss(from: .dismiss, screen: .main, tracker: tracker) - } -} - -// MARK: - DrawerPresentable - -extension BloggingRemindersFlowIntroViewController: DrawerPresentable { - var collapsedHeight: DrawerHeight { - return .intrinsicHeight - } -} - -// MARK: - ChildDrawerPositionable - -extension BloggingRemindersFlowIntroViewController: ChildDrawerPositionable { - var preferredDrawerPosition: DrawerPosition { - return .collapsed - } -} - -// MARK: - Constants - -private enum TextContent { - static let introTitle = NSLocalizedString("Set your blogging reminders", - comment: "Title of the Blogging Reminders Settings screen.") - - static let postPublishingintroDescription = NSLocalizedString("Your post is publishing... in the meantime, set up your blogging reminders on days you want to post.", - comment: "Description on the first screen of the Blogging Reminders Settings flow called aftet post publishing.") - - static let siteSettingsIntroDescription = NSLocalizedString("Set up your blogging reminders on days you want to post.", - comment: "Description on the first screen of the Blogging Reminders Settings flow called from site settings.") - - static let introButtonTitle = NSLocalizedString("Set reminders", - comment: "Title of the set goals button in the Blogging Reminders Settings flow.") -} - -private enum Images { - static let celebrationImageName = "reminders-celebration" -} - -private enum Metrics { - static let edgeMargins = UIEdgeInsets(top: 46, left: 20, bottom: 20, right: 20) - static let stackSpacing: CGFloat = 20.0 - static let afterPromptSpacing: CGFloat = 24.0 - static let getStartedButtonHeight: CGFloat = 44.0 -} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersNavigationController.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersNavigationController.swift deleted file mode 100644 index df093f371e63..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersNavigationController.swift +++ /dev/null @@ -1,123 +0,0 @@ -import UIKit -import WordPressUI - -protocol ChildDrawerPositionable { - var preferredDrawerPosition: DrawerPosition { get } -} - -class BloggingRemindersNavigationController: LightNavigationController { - - typealias DismissClosure = () -> Void - - private let onDismiss: DismissClosure? - - required init(rootViewController: UIViewController, onDismiss: DismissClosure? = nil) { - self.onDismiss = onDismiss - - super.init(rootViewController: rootViewController) - - delegate = self - setNavigationBarHidden(true, animated: false) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - if isBeingDismissedDirectlyOrByAncestor() { - onDismiss?() - } - } - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return .portrait - } - - override public var preferredContentSize: CGSize { - set { - viewControllers.last?.preferredContentSize = newValue - super.preferredContentSize = newValue - } - get { - guard let visibleViewController = viewControllers.last else { - return .zero - } - - return visibleViewController.preferredContentSize - } - } - - override func pushViewController(_ viewController: UIViewController, animated: Bool) { - super.pushViewController(viewController, animated: animated) - - updateDrawerPosition() - } - - override func popViewController(animated: Bool) -> UIViewController? { - let viewController = super.popViewController(animated: animated) - - updateDrawerPosition() - - return viewController - } - - private func updateDrawerPosition() { - if let bottomSheet = self.parent as? BottomSheetViewController, - let presentedVC = bottomSheet.presentedVC, - let currentVC = topViewController as? ChildDrawerPositionable { - presentedVC.transition(to: currentVC.preferredDrawerPosition) - } - } -} - -// MARK: - DrawerPresentable - -extension BloggingRemindersNavigationController: DrawerPresentable { - var allowsUserTransition: Bool { - return false - } - - var allowsDragToDismiss: Bool { - return true - } - - var allowsTapToDismiss: Bool { - return true - } - - var expandedHeight: DrawerHeight { - return .maxHeight - } - - var collapsedHeight: DrawerHeight { - if let viewController = viewControllers.last as? DrawerPresentable { - return viewController.collapsedHeight - } - - return .intrinsicHeight - } - - func handleDismiss() { - (children.last as? DrawerPresentable)?.handleDismiss() - } -} - -// MARK: - NavigationControllerDelegate - -extension BloggingRemindersNavigationController: UINavigationControllerDelegate { - - /// This implementation uses the custom `BloggingRemindersAnimator` to improve screen transitions - /// in the blogging reminders setup flow. - func navigationController(_ navigationController: UINavigationController, - animationControllerFor operation: UINavigationController.Operation, - from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { - - let animator = BloggingRemindersAnimator() - animator.popStyle = (operation == .pop) - - return animator - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionViewController.swift deleted file mode 100644 index 16305668ca88..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionViewController.swift +++ /dev/null @@ -1,86 +0,0 @@ -import UIKit -import WordPressUI - -class TimeSelectionViewController: UIViewController { - - var preferredWidth: CGFloat? - - private let scheduledTime: Date - - private let tracker: BloggingRemindersTracker - - private var onDismiss: ((Date) -> Void)? - - private lazy var timeSelectionView: TimeSelectionView = { - let view = TimeSelectionView(selectedTime: scheduledTime) - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - init(scheduledTime: Date, tracker: BloggingRemindersTracker, onDismiss: ((Date) -> Void)? = nil) { - self.scheduledTime = scheduledTime - self.tracker = tracker - self.onDismiss = onDismiss - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func loadView() { - let mainView = timeSelectionView - if let width = preferredWidth { - mainView.widthAnchor.constraint(equalToConstant: width).isActive = true - } - self.view = mainView - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - calculatePreferredSize() - } - - private func calculatePreferredSize() { - let targetSize = CGSize(width: view.bounds.width, - height: UIView.layoutFittingCompressedSize.height) - preferredContentSize = view.systemLayoutSizeFitting(targetSize) - navigationController?.preferredContentSize = preferredContentSize - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationController?.setNavigationBarHidden(false, animated: false) - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - navigationController?.setNavigationBarHidden(true, animated: false) - if isMovingFromParent { - onDismiss?(timeSelectionView.getDate()) - } - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - // If a parent VC is being dismissed, and this is the last view shown in its navigation controller, we'll assume - // the flow was interrupted. - if isBeingDismissedDirectlyOrByAncestor() && navigationController?.viewControllers.last == self { - tracker.flowDismissed(source: .timePicker) - } - } -} - -// MARK: - DrawerPresentable -extension TimeSelectionViewController: DrawerPresentable { - var collapsedHeight: DrawerHeight { - return .intrinsicHeight - } -} - -extension TimeSelectionViewController: ChildDrawerPositionable { - var preferredDrawerPosition: DrawerPosition { - return .collapsed - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersActions.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersActions.swift similarity index 97% rename from WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersActions.swift rename to WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersActions.swift index b1105e99422a..22b0ac2d858b 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersActions.swift +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersActions.swift @@ -1,3 +1,5 @@ +import UIKit + /// Conform to this protocol to implement common actions for the blogging reminders flow protocol BloggingRemindersActions: UIViewController { func dismiss(from button: BloggingRemindersTracker.Button, diff --git a/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlow.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlow.swift new file mode 100644 index 000000000000..e466effcb2b8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlow.swift @@ -0,0 +1,87 @@ +import UIKit +import WordPressFlux +import WordPressUI + +final class BloggingRemindersFlow { + static func present( + from presentingViewController: UIViewController, + for blog: Blog, + source: BloggingRemindersTracker.FlowStartSource, + alwaysShow: Bool = true, + delegate: BloggingRemindersFlowDelegate? = nil, + onDismiss: (() -> Void)? = nil + ) { + guard !UITestConfigurator.isEnabled(.disablePrompts) else { + return + } + guard blog.areBloggingRemindersAllowed() else { + return + } + + guard alwaysShow || !hasShownWeeklyRemindersFlow(for: blog) else { + return + } + + let blogType: BloggingRemindersTracker.BlogType = blog.isHostedAtWPcom ? .wpcom : .selfHosted + + let tracker = BloggingRemindersTracker(blogType: blogType) + tracker.flowStarted(source: source) + + let showSettings = { [weak presentingViewController] in + do { + let settingsVC = try BloggingRemindersFlowSettingsViewController(for: blog, tracker: tracker, delegate: delegate) + let navigationController = BloggingRemindersNavigationController(rootViewController: settingsVC, onDismiss: { + onDismiss?() + }) + presentingViewController?.present(navigationController, animated: true) + } catch { + wpAssertionFailure("Could not instantiate the blogging reminders settings VC", userInfo: ["error": "\(error)"]) + } + } + + if hasShownWeeklyRemindersFlow(for: blog) { + showSettings() + } else { + let introVC = BloggingRemindersFlowIntroViewController(tracker: tracker) { [weak presentingViewController] in + presentingViewController?.dismiss(animated: true) { + showSettings() + } + } + let navigationVC = UINavigationController(rootViewController: introVC) + if presentingViewController.traitCollection.horizontalSizeClass == .regular { + navigationVC.preferredContentSize = CGSize(width: 375, height: 420) + } else { + navigationVC.sheetPresentationController?.detents = [.medium()] + navigationVC.sheetPresentationController?.preferredCornerRadius = 16 + } + presentingViewController.present(navigationVC, animated: true) + } + + setHasShownWeeklyRemindersFlow(for: blog) + ActionDispatcher.dispatch(NoticeAction.dismiss) + } + + // MARK: - Weekly reminders flow presentation status + // + // stores a key for each blog in UserDefaults to determine if + // the flow was presented for the given blog. + private static func hasShownWeeklyRemindersFlow(for blog: Blog) -> Bool { + UserPersistentStoreFactory.instance().bool(forKey: weeklyRemindersKey(for: blog)) + } + + static func setHasShownWeeklyRemindersFlow(for blog: Blog) { + UserPersistentStoreFactory.instance().set(true, forKey: weeklyRemindersKey(for: blog)) + } + + private static func weeklyRemindersKey(for blog: Blog) -> String { + // weekly reminders key prefix + let prefix = "blogging-reminder-weekly-" + return prefix + blog.objectID.uriRepresentation().absoluteString + } + + /// By making this private we ensure this can't be instantiated. + /// + private init() { + assertionFailure() + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowCompletionViewController.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowCompletionViewController.swift similarity index 68% rename from WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowCompletionViewController.swift rename to WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowCompletionViewController.swift index 63a439c3f8ab..86d16817cfb4 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowCompletionViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowCompletionViewController.swift @@ -1,22 +1,14 @@ import UIKit import WordPressUI -class BloggingRemindersFlowCompletionViewController: UIViewController { +final class BloggingRemindersFlowCompletionViewController: UIViewController { // MARK: - Subviews - private let stackView: UIStackView = { - let stackView = UIStackView() - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.spacing = Metrics.stackSpacing - stackView.axis = .vertical - stackView.alignment = .center - stackView.distribution = .equalSpacing - return stackView - }() + private let scrollView = UIScrollView() private let imageView: UIImageView = { - let imageView = UIImageView(image: UIImage(named: Images.bellImageName)) + let imageView = UIImageView(image: UIImage(named: "reminders-bell")) imageView.translatesAutoresizingMaskIntoConstraints = false imageView.tintColor = .systemYellow return imageView @@ -25,7 +17,6 @@ class BloggingRemindersFlowCompletionViewController: UIViewController { private let titleLabel: UILabel = { let label = UILabel() label.adjustsFontForContentSizeCategory = true - label.adjustsFontSizeToFitWidth = true label.font = WPStyleGuide.serifFontForTextStyle(.title1, fontWeight: .semibold) label.numberOfLines = 2 label.textAlignment = .center @@ -36,7 +27,6 @@ class BloggingRemindersFlowCompletionViewController: UIViewController { private let promptLabel: UILabel = { let label = UILabel() label.adjustsFontForContentSizeCategory = true - label.adjustsFontSizeToFitWidth = true label.font = .preferredFont(forTextStyle: .body) label.numberOfLines = 6 label.textAlignment = .center @@ -47,7 +37,6 @@ class BloggingRemindersFlowCompletionViewController: UIViewController { private let hintLabel: UILabel = { let label = UILabel() label.adjustsFontForContentSizeCategory = true - label.adjustsFontSizeToFitWidth = true label.font = .preferredFont(forTextStyle: .footnote) label.text = TextContent.completionUpdateHint label.numberOfLines = 3 @@ -57,13 +46,17 @@ class BloggingRemindersFlowCompletionViewController: UIViewController { }() private lazy var doneButton: UIButton = { - let button = FancyButton() - button.isPrimary = true + var configuration = UIButton.Configuration.primary() + configuration.title = TextContent.doneButtonTitle + + let button = UIButton(configuration: configuration, primaryAction: nil) button.setTitle(TextContent.doneButtonTitle, for: .normal) button.addTarget(self, action: #selector(doneButtonTapped), for: .touchUpInside) return button }() + private let bottomBarView = BottomToolbarView() + // MARK: - Initializers let blog: Blog @@ -94,10 +87,12 @@ class BloggingRemindersFlowCompletionViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = .systemBackground - configureStackView() - configureConstraints() + setupView() + setupBottomBar() + configurePromptLabel() configureTitleLabel() @@ -105,9 +100,9 @@ class BloggingRemindersFlowCompletionViewController: UIViewController { } override func viewDidAppear(_ animated: Bool) { - tracker.screenShown(.allSet) - super.viewDidAppear(animated) + + tracker.screenShown(.allSet) } override func viewDidDisappear(_ animated: Bool) { @@ -118,50 +113,39 @@ class BloggingRemindersFlowCompletionViewController: UIViewController { if isBeingDismissedDirectlyOrByAncestor() && navigationController?.viewControllers.last == self { tracker.flowCompleted() } - - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - calculatePreferredContentSize() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - hintLabel.isHidden = traitCollection.preferredContentSizeCategory.isAccessibilityCategory - } - - func calculatePreferredContentSize() { - let size = CGSize(width: view.bounds.width, height: UIView.layoutFittingCompressedSize.height) - preferredContentSize = view.systemLayoutSizeFitting(size) } // MARK: - View Configuration - private func configureStackView() { - view.addSubview(stackView) - - stackView.addArrangedSubviews([ + private func setupView() { + let stackView = UIStackView(axis: .vertical, alignment: .center, spacing: 8, [ imageView, titleLabel, promptLabel, - hintLabel, - doneButton + hintLabel ]) - stackView.setCustomSpacing(Metrics.afterHintSpacing, after: hintLabel) + stackView.setCustomSpacing(16, after: titleLabel) + + scrollView.showsVerticalScrollIndicator = false + scrollView.alwaysBounceVertical = false + + scrollView.addSubview(stackView) + view.addSubview(scrollView) + + var insets = UIEdgeInsets(.all, 20) + insets.top = 48 + + stackView.pinEdges(insets: insets) + stackView.widthAnchor.constraint(equalTo: view.widthAnchor, constant: -40).isActive = true + + scrollView.pinEdges() } - private func configureConstraints() { - NSLayoutConstraint.activate([ - stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Metrics.edgeMargins.left), - stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.edgeMargins.right), - stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: Metrics.edgeMargins.top), - stackView.bottomAnchor.constraint(lessThanOrEqualTo: view.safeBottomAnchor, constant: -Metrics.edgeMargins.bottom), + private func setupBottomBar() { + bottomBarView.contentView.addSubview(doneButton) + doneButton.pinEdges() - doneButton.heightAnchor.constraint(greaterThanOrEqualToConstant: Metrics.doneButtonHeight), - doneButton.widthAnchor.constraint(equalTo: stackView.widthAnchor), - ]) + bottomBarView.configure(in: self, scrollView: scrollView) } // Populates the prompt label with formatted text detailing the reminders set by the user. @@ -223,20 +207,6 @@ extension BloggingRemindersFlowCompletionViewController: BloggingRemindersAction } } -// MARK: - DrawerPresentable - -extension BloggingRemindersFlowCompletionViewController: DrawerPresentable { - var collapsedHeight: DrawerHeight { - return .intrinsicHeight - } -} - -extension BloggingRemindersFlowCompletionViewController: ChildDrawerPositionable { - var preferredDrawerPosition: DrawerPosition { - return .collapsed - } -} - // MARK: - Constants private enum TextContent { @@ -250,14 +220,6 @@ private enum TextContent { static let doneButtonTitle = NSLocalizedString("Done", comment: "Title for a Done button.") } -private enum Images { - static let bellImageName = "reminders-bell" -} - private enum Metrics { - static let edgeMargins = UIEdgeInsets(top: 46, left: 20, bottom: 20, right: 20) - static let stackSpacing: CGFloat = 20.0 - static let doneButtonHeight: CGFloat = 44.0 - static let afterHintSpacing: CGFloat = 24.0 static let promptTextLineSpacing: CGFloat = 1.5 } diff --git a/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowIntroViewController.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowIntroViewController.swift new file mode 100644 index 000000000000..8d62ab6b8cd4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowIntroViewController.swift @@ -0,0 +1,138 @@ +import UIKit + import WordPressUI + +final class BloggingRemindersFlowIntroViewController: UIViewController { + private let scrollView = UIScrollView() + + private let imageView: UIImageView = { + let imageView = UIImageView(image: UIImage(named: "reminders-celebration")) + imageView.tintColor = .systemYellow + return imageView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.font = .preferredFont(forTextStyle: .title1).withWeight(.semibold) + label.numberOfLines = 2 + label.textAlignment = .center + label.text = Strings.introTitle + return label + }() + + private let promptLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.font = .preferredFont(forTextStyle: .body) + label.numberOfLines = 5 + label.textAlignment = .center + return label + }() + + private lazy var buttonNext: UIButton = { + var configuration = UIButton.Configuration.primary() + configuration.title = Strings.introButtonTitle + + let button = UIButton(configuration: configuration, primaryAction: .init { [weak self] _ in + self?.buttonContinueTapped() + }) + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + }() + + private let bottomBarView = BottomToolbarView() + + private let tracker: BloggingRemindersTracker + private var isOnNextTapped = false + private let onNextTapped: () -> Void + + init(tracker: BloggingRemindersTracker, onNextTapped: @escaping () -> Void) { + self.tracker = tracker + self.onNextTapped = onNextTapped + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("Use init(tracker:) instead") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + setupView() + setupBottomBar() + + promptLabel.text = Strings.introDescription + + navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .close, primaryAction: .init(handler: { [weak self] _ in + self?.buttonCloseTapped() + })) + } + + override func viewDidAppear(_ animated: Bool) { + tracker.screenShown(.main) + + super.viewDidAppear(animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if !isOnNextTapped { + tracker.flowDismissed(source: .main) + } + } + + // MARK: - View Configuration + + private func setupView() { + let stackView = UIStackView(axis: .vertical, alignment: .center, spacing: 20, [ + imageView, + titleLabel, + promptLabel + ]) + stackView.setCustomSpacing(8, after: titleLabel) + + scrollView.showsVerticalScrollIndicator = false + scrollView.alwaysBounceVertical = false + + scrollView.addSubview(stackView) + view.addSubview(scrollView) + + stackView.pinEdges(insets: UIEdgeInsets(.all, 20)) + stackView.widthAnchor.constraint(equalTo: view.widthAnchor, constant: -40).isActive = true + + scrollView.pinEdges() + } + + private func setupBottomBar() { + bottomBarView.contentView.addSubview(buttonNext) + buttonNext.pinEdges() + + bottomBarView.configure(in: self, scrollView: scrollView) + } + + // MARK: Actions + + private func buttonContinueTapped() { + tracker.buttonPressed(button: .continue, screen: .main) + isOnNextTapped = true + onNextTapped() + } + + private func buttonCloseTapped() { + tracker.buttonPressed(button: .dismiss, screen: .main) + presentingViewController?.dismiss(animated: true, completion: nil) + } +} + +private enum Strings { + static let introTitle = NSLocalizedString("bloggingRemindersPrompt.intro.title", value: "Blogging Reminders", comment: "Title of the Blogging Reminders Settings screen.") + static let introDescription = NSLocalizedString("bloggingRemindersPrompt.intro.details", value: "Set up your blogging reminders on days you want to post.", comment: "Description on the first screen of the Blogging Reminders Settings flow called aftet post publishing.") + static let introButtonTitle = NSLocalizedString("bloggingRemindersPrompt.intro.continueButton", value: "Set Reminders", comment: "Title of the set goals button in the Blogging Reminders Settings flow.") +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowSettingsViewController.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowSettingsViewController.swift similarity index 96% rename from WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowSettingsViewController.swift rename to WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowSettingsViewController.swift index 17af112e0157..99f93f2931e4 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowSettingsViewController.swift @@ -6,7 +6,7 @@ protocol BloggingRemindersFlowDelegate: AnyObject { func didSetUpBloggingReminders() } -class BloggingRemindersFlowSettingsViewController: UIViewController { +final class BloggingRemindersFlowSettingsViewController: UIViewController { // MARK: - Subviews @@ -54,8 +54,8 @@ class BloggingRemindersFlowSettingsViewController: UIViewController { }() private lazy var button: UIButton = { - let button = FancyButton() - button.isPrimary = true + var configuration = UIButton.Configuration.primary() + let button = UIButton(configuration: configuration, primaryAction: nil) button.addTarget(self, action: #selector(notifyMeButtonTapped), for: .touchUpInside) return button }() @@ -110,8 +110,8 @@ class BloggingRemindersFlowSettingsViewController: UIViewController { makeDivider() }() - private lazy var timeSelectionButton: TimeSelectionButton = { - let button = TimeSelectionButton(selectedTime: scheduledTime.toLocalTime()) + private lazy var timeSelectionButton: BloggingRemindersTimeSelectionButton = { + let button = BloggingRemindersTimeSelectionButton(selectedTime: scheduledTime.toLocalTime()) button.isUserInteractionEnabled = true button.translatesAutoresizingMaskIntoConstraints = false button.addTarget(self, action: #selector(navigateToTimePicker), for: .touchUpInside) @@ -279,13 +279,16 @@ class BloggingRemindersFlowSettingsViewController: UIViewController { refreshFrequencyLabel() showFullUI(shouldShowFullUI) + + navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .close, primaryAction: .init(handler: { [weak self] _ in + self?.presentingViewController?.dismiss(animated: true) + })) } override func viewDidAppear(_ animated: Bool) { - tracker.screenShown(.dayPicker) - super.viewDidAppear(animated) - calculatePreferredContentSize() + + tracker.screenShown(.dayPicker) } override func viewDidDisappear(_ animated: Bool) { @@ -298,11 +301,6 @@ class BloggingRemindersFlowSettingsViewController: UIViewController { } } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - calculatePreferredContentSize() - } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) @@ -311,8 +309,8 @@ class BloggingRemindersFlowSettingsViewController: UIViewController { override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) + showFullUI(shouldShowFullUI) - calculatePreferredContentSize() } // MARK: - Actions @@ -408,14 +406,12 @@ class BloggingRemindersFlowSettingsViewController: UIViewController { private extension BloggingRemindersFlowSettingsViewController { func pushTimeSelectionViewController() { - let viewController = TimeSelectionViewController(scheduledTime: scheduler.scheduledTime(for: blog), - tracker: tracker) { [weak self] date in + let viewController = BloggingRemindersTimeSelectionViewController(scheduledTime: scheduler.scheduledTime(for: blog), tracker: tracker) { [weak self] date in self?.scheduledTime = date self?.timeSelectionButton.setSelectedTime(date.toLocalTime()) self?.refreshNextButton() self?.refreshFrequencyLabel() } - viewController.preferredWidth = self.view.frame.width navigationController?.pushViewController(viewController, animated: true) } @@ -499,11 +495,6 @@ private extension BloggingRemindersFlowSettingsViewController { frequencyLabel.sizeToFit() } - func calculatePreferredContentSize() { - let size = CGSize(width: view.bounds.width, height: UIView.layoutFittingCompressedSize.height) - preferredContentSize = view.systemLayoutSizeFitting(size) - } - func configureStackView() { view.addSubview(stackView) @@ -653,18 +644,6 @@ extension BloggingRemindersFlowSettingsViewController: BloggingRemindersActions } } -extension BloggingRemindersFlowSettingsViewController: DrawerPresentable { - var collapsedHeight: DrawerHeight { - return .maxHeight - } -} - -extension BloggingRemindersFlowSettingsViewController: ChildDrawerPositionable { - var preferredDrawerPosition: DrawerPosition { - return .expanded - } -} - // MARK: - Blogging Prompts Helpers private extension BloggingRemindersFlowSettingsViewController { diff --git a/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersNavigationController.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersNavigationController.swift new file mode 100644 index 000000000000..ae119f8e8ad3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersNavigationController.swift @@ -0,0 +1,30 @@ +import UIKit +import WordPressUI + +final class BloggingRemindersNavigationController: UINavigationController { + private let onDismiss: (() -> Void)? + + required init(rootViewController: UIViewController, onDismiss: (() -> Void)? = nil) { + self.onDismiss = onDismiss + + super.init(rootViewController: rootViewController) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if isBeingDismissedDirectlyOrByAncestor() { + onDismiss?() + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersPushPromptViewController.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersPushPromptViewController.swift similarity index 79% rename from WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersPushPromptViewController.swift rename to WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersPushPromptViewController.swift index 5c694e8b4118..51e05e817800 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersPushPromptViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersPushPromptViewController.swift @@ -1,7 +1,7 @@ import UIKit import WordPressUI -class BloggingRemindersPushPromptViewController: UIViewController { +final class BloggingRemindersPushPromptViewController: UIViewController { // MARK: - Subviews @@ -58,21 +58,12 @@ class BloggingRemindersPushPromptViewController: UIViewController { }() private lazy var turnOnNotificationsButton: UIButton = { - let button = FancyButton() - button.translatesAutoresizingMaskIntoConstraints = false - button.isPrimary = true - button.setTitle(TextContent.turnOnButtonTitle, for: .normal) - button.addTarget(self, action: #selector(turnOnButtonTapped), for: .touchUpInside) - button.titleLabel?.adjustsFontSizeToFitWidth = true - return button - }() + var configuration = UIButton.Configuration.primary() + configuration.title = TextContent.turnOnButtonTitle - private lazy var dismissButton: UIButton = { - let button = UIButton(type: .custom) + let button = UIButton(configuration: configuration, primaryAction: nil) button.translatesAutoresizingMaskIntoConstraints = false - button.setImage(.gridicon(.cross), for: .normal) - button.tintColor = .secondaryLabel - button.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) + button.addTarget(self, action: #selector(turnOnButtonTapped), for: .touchUpInside) return button }() @@ -118,14 +109,11 @@ class BloggingRemindersPushPromptViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground - view.addSubview(dismissButton) configureStackView() view.addSubview(turnOnNotificationsButton) configureConstraints() - - navigationController?.setNavigationBarHidden(true, animated: false) } override func viewDidAppear(_ animated: Bool) { @@ -145,22 +133,12 @@ class BloggingRemindersPushPromptViewController: UIViewController { } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - calculatePreferredContentSize() - } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) hintLabel.isHidden = traitCollection.preferredContentSizeCategory.isAccessibilityCategory } - private func calculatePreferredContentSize() { - let size = CGSize(width: view.bounds.width, height: UIView.layoutFittingCompressedSize.height) - preferredContentSize = view.systemLayoutSizeFitting(size) - } - @objc private func applicationBecameActive() { refreshPushAuthorizationStatus() @@ -186,13 +164,9 @@ class BloggingRemindersPushPromptViewController: UIViewController { stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: Metrics.edgeMargins.top), turnOnNotificationsButton.topAnchor.constraint(greaterThanOrEqualTo: stackView.bottomAnchor, constant: Metrics.edgeMargins.bottom), - turnOnNotificationsButton.heightAnchor.constraint(greaterThanOrEqualToConstant: Metrics.turnOnButtonHeight), turnOnNotificationsButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Metrics.edgeMargins.left), turnOnNotificationsButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.edgeMargins.right), - turnOnNotificationsButton.bottomAnchor.constraint(equalTo: view.safeBottomAnchor, constant: -Metrics.edgeMargins.bottom), - - dismissButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.dismissButtonMargin), - dismissButton.topAnchor.constraint(equalTo: view.topAnchor, constant: Metrics.dismissButtonMargin) + turnOnNotificationsButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -Metrics.edgeMargins.bottom), ]) } @@ -230,20 +204,6 @@ extension BloggingRemindersPushPromptViewController: BloggingRemindersActions { } } -// MARK: - DrawerPresentable - -extension BloggingRemindersPushPromptViewController: DrawerPresentable { - var collapsedHeight: DrawerHeight { - return .maxHeight - } -} - -extension BloggingRemindersPushPromptViewController: ChildDrawerPositionable { - var preferredDrawerPosition: DrawerPosition { - return .expanded - } -} - // MARK: - Constants private enum TextContent { @@ -263,8 +223,6 @@ private enum Images { } private enum Metrics { - static let dismissButtonMargin: CGFloat = 20.0 - static let edgeMargins = UIEdgeInsets(top: 80, left: 28, bottom: 80, right: 28) + static let edgeMargins = UIEdgeInsets(top: 80, left: 20, bottom: 20, right: 20) static let stackSpacing: CGFloat = 20.0 - static let turnOnButtonHeight: CGFloat = 44.0 } diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersTracker.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersTracker.swift similarity index 100% rename from WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersTracker.swift rename to WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersTracker.swift diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/CalendarDayToggleButton.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/CalendarDayToggleButton.swift similarity index 100% rename from WordPress/Classes/ViewRelated/Blog/Blogging Reminders/CalendarDayToggleButton.swift rename to WordPress/Classes/ViewRelated/Blog/BloggingReminders/CalendarDayToggleButton.swift diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionButton.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/TimeSelector/BloggingRemindersTimeSelectionButton.swift similarity index 95% rename from WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionButton.swift rename to WordPress/Classes/ViewRelated/Blog/BloggingReminders/TimeSelector/BloggingRemindersTimeSelectionButton.swift index 2dd59d12c661..2b6e3f606916 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionButton.swift +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/TimeSelector/BloggingRemindersTimeSelectionButton.swift @@ -1,6 +1,6 @@ import UIKit -class TimeSelectionButton: UIButton { +final class BloggingRemindersTimeSelectionButton: UIButton { private(set) var selectedTime: String { didSet { @@ -52,7 +52,7 @@ class TimeSelectionButton: UIButton { private lazy var chevron: UIImageView = { let imageView = UIImageView() imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.image = UIImage.gridicon(.chevronRight) + imageView.image = UIImage(systemName: "chevron.forward") imageView.tintColor = .separator return imageView }() diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionView.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/TimeSelector/BloggingRemindersTimeSelectionView.swift similarity index 88% rename from WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionView.swift rename to WordPress/Classes/ViewRelated/Blog/BloggingReminders/TimeSelector/BloggingRemindersTimeSelectionView.swift index 973353ba2781..0160e57fc9c7 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionView.swift +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/TimeSelector/BloggingRemindersTimeSelectionView.swift @@ -1,7 +1,8 @@ import UIKit +import WordPressUI /// A view that contains a time picker and a title reporting the selected time -class TimeSelectionView: UIView { +final class BloggingRemindersTimeSelectionView: UIView { private var selectedTime: Date @@ -19,8 +20,8 @@ class TimeSelectionView: UIView { titleBar.setSelectedTime(timePicker.date.toLocalTime()) } - private lazy var titleBar: TimeSelectionButton = { - let button = TimeSelectionButton(selectedTime: selectedTime.toLocalTime(), insets: Self.titleInsets) + private lazy var titleBar: BloggingRemindersTimeSelectionButton = { + let button = BloggingRemindersTimeSelectionButton(selectedTime: selectedTime.toLocalTime(), insets: Self.titleInsets) button.translatesAutoresizingMaskIntoConstraints = false button.isUserInteractionEnabled = false button.isChevronHidden = true @@ -65,9 +66,9 @@ class TimeSelectionView: UIView { self.selectedTime = selectedTime super.init(frame: .zero) - backgroundColor = .systemBackground addSubview(verticalStackView) - pinSubviewToSafeArea(verticalStackView) + verticalStackView.pinEdges(to: safeAreaLayoutGuide) + NSLayoutConstraint.activate([ timePicker.centerXAnchor.constraint(equalTo: centerXAnchor), titleBar.widthAnchor.constraint(equalTo: widthAnchor), diff --git a/WordPress/Classes/ViewRelated/Blog/BloggingReminders/TimeSelector/BloggingRemindersTimeSelectionViewController.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/TimeSelector/BloggingRemindersTimeSelectionViewController.swift new file mode 100644 index 000000000000..99b75e03bda1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/TimeSelector/BloggingRemindersTimeSelectionViewController.swift @@ -0,0 +1,50 @@ +import UIKit +import WordPressUI + +final class BloggingRemindersTimeSelectionViewController: UIViewController { + private let scheduledTime: Date + + private let tracker: BloggingRemindersTracker + + private var onDismiss: ((Date) -> Void)? + + private lazy var timeSelectionView = BloggingRemindersTimeSelectionView(selectedTime: scheduledTime) + + init(scheduledTime: Date, tracker: BloggingRemindersTracker, onDismiss: ((Date) -> Void)? = nil) { + self.scheduledTime = scheduledTime + self.tracker = tracker + self.onDismiss = onDismiss + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + view.addSubview(timeSelectionView) + timeSelectionView.pinEdges([.top, .horizontal]) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if isMovingFromParent { + onDismiss?(timeSelectionView.getDate()) + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + // If a parent VC is being dismissed, and this is the last view shown in its navigation controller, we'll assume + // the flow was interrupted. + if isBeingDismissedDirectlyOrByAncestor() && navigationController?.viewControllers.last == self { + tracker.flowDismissed(source: .timePicker) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/Header/HomeSiteHeaderViewController+SiteActions.swift b/WordPress/Classes/ViewRelated/Blog/My Site/Header/HomeSiteHeaderViewController+SiteActions.swift index e5403159061a..0f8966bb6ffa 100644 --- a/WordPress/Classes/ViewRelated/Blog/My Site/Header/HomeSiteHeaderViewController+SiteActions.swift +++ b/WordPress/Classes/ViewRelated/Blog/My Site/Header/HomeSiteHeaderViewController+SiteActions.swift @@ -22,7 +22,7 @@ extension HomeSiteHeaderViewController { private func makePrimarySection() -> UIMenu { let menuItems = [ - MenuItem.visitSite({ [weak self] in self?.visitSiteTapped() }), + MenuItem.visitSite { [weak self] in self?.visitSiteTapped() }, MenuItem.shareSite { [weak self] in self?.buttonShareSiteTapped() }, ] return UIMenu(options: .displayInline, children: menuItems.map { $0.toAction }) @@ -54,7 +54,7 @@ extension HomeSiteHeaderViewController { // MARK: - Actions - private func buttonShareSiteTapped() { + func buttonShareSiteTapped() { guard let urlString = blog.homeURL as String?, let url = URL(string: urlString) else { assertionFailure("Site has no URL") @@ -108,7 +108,7 @@ private enum MenuItem { var title: String { switch self { case .visitSite: return Strings.visitSite - case .shareSite: return Strings.shareSite + case .shareSite: return SharedStrings.Button.share + "…" case .siteTitle: return Strings.siteTitle case .personalizeHome: return Strings.personalizeHome } @@ -136,7 +136,6 @@ private enum MenuItem { private enum Strings { static let visitSite = NSLocalizedString("mySite.siteActions.visitSite", value: "Visit site", comment: "Menu title for the visit site option") - static let shareSite = NSLocalizedString("mySite.siteActions.shareSite", value: "Share site", comment: "Menu title for the share site option") static let siteTitle = NSLocalizedString("mySite.siteActions.siteTitle", value: "Change site title", comment: "Menu title for the change site title option") static let siteIcon = NSLocalizedString("mySite.siteActions.siteIcon", value: "Change site icon", comment: "Menu title for the change site icon option") static let personalizeHome = NSLocalizedString("mySite.siteActions.personalizeHome", value: "Personalize home", comment: "Menu title for the personalize home option") diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/Header/HomeSiteHeaderViewController+SiteIcon.swift b/WordPress/Classes/ViewRelated/Blog/My Site/Header/HomeSiteHeaderViewController+SiteIcon.swift index fa108934eb09..7e05c14d1013 100644 --- a/WordPress/Classes/ViewRelated/Blog/My Site/Header/HomeSiteHeaderViewController+SiteIcon.swift +++ b/WordPress/Classes/ViewRelated/Blog/My Site/Header/HomeSiteHeaderViewController+SiteIcon.swift @@ -28,7 +28,7 @@ extension HomeSiteHeaderViewController { mediaMenu.makeCameraAction(delegate: presenter), mediaMenu.makeImagePlaygroundAction(delegate: presenter), mediaMenu.makeSiteMediaAction(blog: blog, delegate: presenter) - ] + ].compactMap { $0 } if FeatureFlag.siteIconCreator.enabled { actions.append(UIAction( title: SiteIconAlertStrings.Actions.createWithEmoji, diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/Header/HomeSiteHeaderViewController.swift b/WordPress/Classes/ViewRelated/Blog/My Site/Header/HomeSiteHeaderViewController.swift index 2d9150326fbb..ff200d4d21a0 100644 --- a/WordPress/Classes/ViewRelated/Blog/My Site/Header/HomeSiteHeaderViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/My Site/Header/HomeSiteHeaderViewController.swift @@ -255,7 +255,7 @@ extension HomeSiteHeaderViewController { onClose: nil ) - let navigationController = LightNavigationController(rootViewController: webViewController) + let navigationController = UINavigationController(rootViewController: webViewController) if traitCollection.userInterfaceIdiom == .pad { navigationController.modalPresentationStyle = .fullScreen diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/NoSitesView.swift b/WordPress/Classes/ViewRelated/Blog/My Site/NoSitesView.swift index 8e405a0e1eb8..cec681d6fcdc 100644 --- a/WordPress/Classes/ViewRelated/Blog/My Site/NoSitesView.swift +++ b/WordPress/Classes/ViewRelated/Blog/My Site/NoSitesView.swift @@ -59,7 +59,7 @@ struct NoSitesView: View { makeGravatarIcon(size: 40) accountAndSettingsStackView Spacer() - Image(systemName: "chevron.right") + Image(systemName: "chevron.forward") .tint(.secondary) } .padding(.horizontal, 16) diff --git a/WordPress/Classes/ViewRelated/Blog/Sharing/KeyringAccountHelper.swift b/WordPress/Classes/ViewRelated/Blog/Sharing/KeyringAccountHelper.swift index c549d73a78d3..a68d0bdf80b7 100644 --- a/WordPress/Classes/ViewRelated/Blog/Sharing/KeyringAccountHelper.swift +++ b/WordPress/Classes/ViewRelated/Blog/Sharing/KeyringAccountHelper.swift @@ -86,7 +86,7 @@ private extension KeyringAccountHelper { let alertBodyMessage = NSLocalizedString("The Facebook connection cannot find any Pages. Publicize cannot connect to Facebook Profiles, only published Pages.", comment: "Error message shown to a user who is trying to share to Facebook but does not have any available Facebook Pages.") let continueActionTitle = NSLocalizedString("Learn more", comment: "A button title.") - let cancelActionTitle = NSLocalizedString("OK", comment: "A button title for closing the dialog.") + let cancelActionTitle = SharedStrings.Button.ok return ValidationError(header: alertHeaderMessage, body: alertBodyMessage, diff --git a/WordPress/Classes/ViewRelated/Blog/Sharing/PublicizeServiceCell.swift b/WordPress/Classes/ViewRelated/Blog/Sharing/PublicizeServiceCell.swift new file mode 100644 index 000000000000..f4e91cd1b705 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Sharing/PublicizeServiceCell.swift @@ -0,0 +1,91 @@ +import UIKit +import WordPressUI +import AsyncImageKit + +final class PublicizeServiceCell: UITableViewCell { + let iconView = AsyncImageView() + let titleLabel = UILabel() + let detailsLabel = UILabel() + + @objc class var cellId: String { "PublicizeServiceCell" } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + detailsLabel.font = .preferredFont(forTextStyle: .footnote) + detailsLabel.textColor = .secondaryLabel + + let stackView = UIStackView(alignment: .center, spacing: 12, [ + iconView, + UIStackView(axis: .vertical, alignment: .leading, spacing: 2, [titleLabel, detailsLabel]) + ]) + contentView.addSubview(stackView) + stackView.pinEdges(to: contentView.layoutMarginsGuide) + + NSLayoutConstraint.activate([ + iconView.widthAnchor.constraint(equalToConstant: 28), + iconView.heightAnchor.constraint(equalToConstant: 28), + ]) + iconView.layer.cornerRadius = 8 + iconView.layer.masksToBounds = true + iconView.backgroundColor = UIColor.white + + iconView.contentMode = .scaleAspectFit + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + accessoryView = .none + iconView.prepareForReuse() + } + + @objc func configure(with service: PublicizeService, connections: [PublicizeConnection]) { + let name = service.name + if name != .unknown && !name.hasModernRemoteLogo { + iconView.image = name.localIconImage + } else if let imageURL = URL(string: service.icon) { + iconView.setImage(with: imageURL) + } else { + iconView.image = UIImage(named: "social-default") + } + + titleLabel.text = service.label + + detailsLabel.isHidden = connections.isEmpty + if connections.count > 2 { + detailsLabel.text = String(format: Strings.numberOfAccounts, connections.count) + } else { + detailsLabel.text = connections + .map(\.externalDisplay) + .joined(separator: ", ") + } + + if service.isSupported { + if connections.contains(where: { $0.requiresUserAction() }) { + accessoryView = WPStyleGuide.sharingCellWarningAccessoryImageView() + } + } else { + accessoryView = WPStyleGuide.sharingCellErrorAccessoryImageView() + } + } +} + +private extension PublicizeService.ServiceName { + /// We no longer need to provide local overrides for these on this screen + /// as the remote images are good. + var hasModernRemoteLogo: Bool { + [ + PublicizeService.ServiceName.instagram, + PublicizeService.ServiceName.mastodon + ].contains(self) + } +} + +private enum Strings { + static let numberOfAccounts = NSLocalizedString("socialSharing.connectionDetails.nAccount", value: "%d accounts", comment: "The number of connected accounts on a third party sharing service connected to the user's blog. The '%d' is a placeholder for the number of accounts.") +} diff --git a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingAuthorizationHelper.m b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingAuthorizationHelper.m index 30436b269ca2..0b64b9e8aef6 100644 --- a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingAuthorizationHelper.m +++ b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingAuthorizationHelper.m @@ -91,7 +91,7 @@ - (void)authorizeWithConnectionURL:(NSURL *)connectionURL { SharingAuthorizationWebViewController *webViewController = [[SharingAuthorizationWebViewController alloc] initWith:self.publicizeService url:connectionURL for:self.blog delegate:self]; - self.navController = [[LightNavigationController alloc] initWithRootViewController:webViewController]; + self.navController = [[UINavigationController alloc] initWithRootViewController:webViewController]; self.navController.modalPresentationStyle = UIModalPresentationFormSheet; [self.viewController presentViewController:self.navController animated:YES completion:nil]; } diff --git a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingButtonsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingButtonsViewController.swift index e2eb28ad0cf2..833e17ee896d 100644 --- a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingButtonsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingButtonsViewController.swift @@ -675,7 +675,7 @@ import WordPressShared message.append(error.localizedDescription) } let controller = UIAlertController(title: title, message: message, preferredStyle: .alert) - controller.addCancelActionWithTitle(NSLocalizedString("OK", comment: "A button title."), handler: nil) + controller.addCancelActionWithTitle(SharedStrings.Button.ok, handler: nil) controller.presentFromRootViewController() } diff --git a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingViewController.m b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingViewController.m index 077c4846c677..0ddf6bc3eda4 100644 --- a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingViewController.m @@ -66,6 +66,7 @@ - (void)viewDidLoad action:@selector(doneButtonTapped)]; } + [self.tableView registerClass:[PublicizeServiceCell class] forCellReuseIdentifier:PublicizeServiceCell.cellId]; self.tableView.cellLayoutMarginsFollowReadableWidth = YES; [WPStyleGuide configureColorsForView:self.view andTableView:self.tableView]; [self.publicizeServicesState addInitialConnections:[self allConnections]]; @@ -237,32 +238,16 @@ - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPa - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - WPTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier]; - if (!cell) { - cell = [[WPTableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:CellIdentifier]; - } - - [WPStyleGuide configureTableViewCell:cell]; - cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; - SharingSectionType sectionType = [self sectionTypeForIndex:indexPath.section]; switch (sectionType) { case SharingSectionAvailableServices: // fallthrough case SharingSectionUnsupported: - [self configurePublicizeCell:cell atIndexPath:indexPath]; - break; - + return [self makePublicizeCellAtIndexPath:indexPath]; case SharingSectionSharingButtons: - cell.textLabel.text = NSLocalizedString(@"Manage", @"Verb. Text label. Tapping displays a screen where the user can configure 'share' buttons for third-party services."); - cell.detailTextLabel.text = nil; - cell.imageView.image = nil; - break; - + return [self makeManageButtonCell]; default: return [UITableViewCell new]; } - - return cell; } - (PublicizeService *)publicizeServiceForIndexPath:(NSIndexPath *)indexPath @@ -278,51 +263,38 @@ - (PublicizeService *)publicizeServiceForIndexPath:(NSIndexPath *)indexPath } } -- (void)configurePublicizeCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath +- (UITableViewCell *)makePublicizeCellAtIndexPath:(NSIndexPath *)indexPath { + PublicizeServiceCell *cell = [self.tableView dequeueReusableCellWithIdentifier:PublicizeServiceCell.cellId]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + PublicizeService *publicizer = [self publicizeServiceForIndexPath:indexPath]; NSArray *connections = [self connectionsForService:publicizer]; // TODO: Remove? if ([publicizer.serviceID isEqualToString:PublicizeService.googlePlusServiceID] && [connections count] == 0) { // Temporarily hiding Google+ cell.hidden = YES; - return; - } - - // Configure the image - UIImage *image = [WPStyleGuide socialIconFor:publicizer.serviceID]; - [cell.imageView setImage:image]; - - // Configure the text - cell.textLabel.text = publicizer.label; - - // Show the name(s) or number of connections. - NSString *str = @""; - if ([connections count] > 2) { - NSString *format = NSLocalizedString(@"%d accounts", @"The number of connected accounts on a third party sharing service connected to the user's blog. The '%d' is a placeholder for the number of accounts."); - str = [NSString stringWithFormat:format, [connections count]]; - } else { - NSMutableArray *names = [NSMutableArray array]; - for (PublicizeConnection *pubConn in connections) { - [names addObject:pubConn.externalDisplay]; - } - str = [names componentsJoinedByString:@", "]; + return cell; } - cell.detailTextLabel.text = str; + [cell configureWith:publicizer connections:connections]; + return cell; +} - if (![publicizer isSupported]) { - cell.accessoryView = [WPStyleGuide sharingCellErrorAccessoryImageView]; - return; +- (UITableViewCell *)makeManageButtonCell +{ + WPTableViewCell *cell = [self. + tableView dequeueReusableCellWithIdentifier:CellIdentifier]; + if (!cell) { + cell = [[WPTableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:CellIdentifier]; } - // Check if any of the connections are broken. - for (PublicizeConnection *pubConn in connections) { - if ([pubConn requiresUserAction]) { - cell.accessoryView = [WPStyleGuide sharingCellWarningAccessoryImageView]; - break; - } - } + [WPStyleGuide configureTableViewCell:cell]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + cell.textLabel.text = NSLocalizedString(@"Manage", @"Verb. Text label. Tapping displays a screen where the user can configure 'share' buttons for third-party services."); + cell.detailTextLabel.text = nil; + cell.imageView.image = nil; + return cell; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath diff --git a/WordPress/Classes/ViewRelated/Blog/Sharing/TwitterDeprecationTableFooterView.swift b/WordPress/Classes/ViewRelated/Blog/Sharing/TwitterDeprecationTableFooterView.swift index 22236f729dc8..304258e36e66 100644 --- a/WordPress/Classes/ViewRelated/Blog/Sharing/TwitterDeprecationTableFooterView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Sharing/TwitterDeprecationTableFooterView.swift @@ -27,7 +27,7 @@ let hyperlinkText = NSAttributedString(string: Constants.hyperlinkText, attributes: [ .paragraphStyle: paragraphStyle, .attachment: attachmentURL, - .foregroundColor: UIAppColor.brand + .foregroundColor: UIAppColor.primary ]) attributedString.append(hyperlinkText) } diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/DeleteSiteViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/DeleteSiteViewController.swift index 5e62d654abed..92abdc23ef10 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Management/DeleteSiteViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Management/DeleteSiteViewController.swift @@ -215,34 +215,31 @@ open class DeleteSiteViewController: UITableViewController { let trackedBlog = blog WPAppAnalytics.track(.siteSettingsDeleteSiteRequested, with: trackedBlog) let service = SiteManagementService(coreDataStack: ContextManager.sharedInstance()) - service.deleteSiteForBlog(blog, - success: { [weak self] in - WPAppAnalytics.track(.siteSettingsDeleteSiteResponseOK, with: trackedBlog) - let status = NSLocalizedString("Site deleted", comment: "Overlay message displayed when site successfully deleted") - SVProgressHUD.showDismissibleSuccess(withStatus: status) - - self?.updateNavigationStackAfterSiteDeletion() - - let context = ContextManager.shared.mainContext - let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) - if let account { - AccountService(coreDataStack: ContextManager.sharedInstance()).updateUserDetails(for: account, - success: {}, - failure: { _ in }) - } - }, - failure: { error in - DDLogError("Error deleting site: \(error.localizedDescription)") - WPAppAnalytics.track(.siteSettingsDeleteSiteResponseError, with: trackedBlog) - SVProgressHUD.dismiss() - - let errorTitle = NSLocalizedString("Delete Site Error", comment: "Title of alert when site deletion fails") - let alertController = UIAlertController(title: errorTitle, message: error.localizedDescription, preferredStyle: .alert) - - let okTitle = NSLocalizedString("OK", comment: "Alert dismissal title") - alertController.addDefaultActionWithTitle(okTitle, handler: nil) - - alertController.presentFromRootViewController() + service.deleteSiteForBlog(blog, success: { [weak self] in + WPAppAnalytics.track(.siteSettingsDeleteSiteResponseOK, with: trackedBlog) + let status = NSLocalizedString("Site deleted", comment: "Overlay message displayed when site successfully deleted") + SVProgressHUD.showDismissibleSuccess(withStatus: status) + + self?.updateNavigationStackAfterSiteDeletion() + + let context = ContextManager.shared.mainContext + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) + if let account { + AccountService(coreDataStack: ContextManager.sharedInstance()).updateUserDetails(for: account, + success: {}, + failure: { _ in }) + } + }, failure: { error in + DDLogError("Error deleting site: \(error.localizedDescription)") + WPAppAnalytics.track(.siteSettingsDeleteSiteResponseError, with: trackedBlog) + SVProgressHUD.dismiss() + + let errorTitle = NSLocalizedString("Delete Site Error", comment: "Title of alert when site deletion fails") + let alertController = UIAlertController(title: errorTitle, message: error.localizedDescription, preferredStyle: .alert) + + alertController.addDefaultActionWithTitle(SharedStrings.Button.ok, handler: nil) + + alertController.presentFromRootViewController() }) } diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/SiteSettingsViewController+SiteManagement.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/SiteSettingsViewController+SiteManagement.swift index 061cff44b7f4..bbf63ae4bdac 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Management/SiteSettingsViewController+SiteManagement.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Management/SiteSettingsViewController+SiteManagement.swift @@ -62,7 +62,7 @@ public extension SiteSettingsViewController { let errorTitle = NSLocalizedString("Export Content Error", comment: "Title of alert when export content fails") let alertController = UIAlertController(title: errorTitle, message: error.localizedDescription, preferredStyle: .alert) - let okTitle = NSLocalizedString("OK", comment: "Alert dismissal title") + let okTitle = SharedStrings.Button.ok _ = alertController.addDefaultActionWithTitle(okTitle, handler: nil) alertController.presentFromRootViewController() @@ -101,7 +101,7 @@ public extension SiteSettingsViewController { let errorTitle = NSLocalizedString("Check Purchases Error", comment: "Title of alert when getting purchases fails") let alertController = UIAlertController(title: errorTitle, message: error.localizedDescription, preferredStyle: .alert) - let okTitle = NSLocalizedString("OK", comment: "Alert dismissal title") + let okTitle = SharedStrings.Button.ok alertController.addDefaultActionWithTitle(okTitle, handler: nil) alertController.presentFromRootViewController() diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift index b65590deea58..e0dd2ecc49d8 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift @@ -314,7 +314,7 @@ extension SiteTagsViewController { comment: "Message of the alert indicating that a tag with that name already exists. The placeholder is the name of the tag"), tagName) - let acceptTitle = NSLocalizedString("OK", comment: "Alert dismissal title") + let acceptTitle = SharedStrings.Button.ok let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) alertController.addDefaultActionWithTitle(acceptTitle) present(alertController, animated: true) diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/StartOverViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/StartOverViewController.swift index 359e182588c6..e1e089fa7861 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Management/StartOverViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Management/StartOverViewController.swift @@ -145,11 +145,8 @@ open class StartOverViewController: UITableViewController, MFMailComposeViewCont let title = String(format: NSLocalizedString("Contact us at %@", comment: "Alert title for contact us alert, placeholder for help email address, inserted at run time."), mailRecipient) let message = NSLocalizedString("\nPlease send us an email to have your content cleared out.", comment: "Message to ask the user to send us an email to clear their content.") - let alertController = UIAlertController(title: title, - message: message, - preferredStyle: .alert) - alertController.addCancelActionWithTitle(NSLocalizedString("OK", - comment: "Button title. An acknowledgement of the message displayed in a prompt.")) + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alertController.addCancelActionWithTitle(SharedStrings.Button.ok) alertController.presentFromRootViewController() } diff --git a/WordPress/Classes/ViewRelated/Blog/Site Monitoring/PHPLogsView.swift b/WordPress/Classes/ViewRelated/Blog/Site Monitoring/PHPLogsView.swift index 4111ecd1b4d3..01336a5885aa 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Monitoring/PHPLogsView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Monitoring/PHPLogsView.swift @@ -110,13 +110,13 @@ struct PHPLogsView: View { PHPLogsEntryRowView(entry: entry) .swipeActions(edge: .trailing) { ShareLink(item: attributedDescription.string) { - Label("Share", systemImage: "square.and.arrow.up") + Label(SharedStrings.Button.share, systemImage: "square.and.arrow.up") } .tint(Color.blue) } .contextMenu { ShareLink(item: attributedDescription.string) { - Label("Share", systemImage: "square.and.arrow.up") + Label(SharedStrings.Button.share, systemImage: "square.and.arrow.up") } } preview: { Text(AttributedString(attributedDescription)) diff --git a/WordPress/Classes/ViewRelated/Blog/Site Monitoring/SiteMonitoringEntryDetailsView.swift b/WordPress/Classes/ViewRelated/Blog/Site Monitoring/SiteMonitoringEntryDetailsView.swift index e359a9f758c5..b1ce914441a9 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Monitoring/SiteMonitoringEntryDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Monitoring/SiteMonitoringEntryDetailsView.swift @@ -9,7 +9,7 @@ struct SiteMonitoringEntryDetailsView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ShareLink(item: text.string) { - Label("Share", systemImage: "square.and.arrow.up") + Label(SharedStrings.Button.share, systemImage: "square.and.arrow.up") } } } diff --git a/WordPress/Classes/ViewRelated/Blog/Site Monitoring/WebServerLogsView.swift b/WordPress/Classes/ViewRelated/Blog/Site Monitoring/WebServerLogsView.swift index 65393a47c543..3370310de483 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Monitoring/WebServerLogsView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Monitoring/WebServerLogsView.swift @@ -123,13 +123,13 @@ struct WebServerLogsView: View { WebServerLogsRowView(entry: entry, width: width) .swipeActions(edge: .trailing) { ShareLink(item: attributedDescription.string) { - Label("Share", systemImage: "square.and.arrow.up") + Label(SharedStrings.Button.share, systemImage: "square.and.arrow.up") } .tint(Color.blue) } .contextMenu { ShareLink(item: attributedDescription.string) { - Label("Share", systemImage: "square.and.arrow.up") + Label(SharedStrings.Button.share, systemImage: "square.and.arrow.up") } } preview: { Text(AttributedString(attributedDescription)) diff --git a/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/BlogListSiteView.swift b/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/BlogListSiteView.swift index d701c2809db8..8ee2a7bd9525 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/BlogListSiteView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/BlogListSiteView.swift @@ -1,4 +1,5 @@ import SwiftUI +import WordPressUI import WordPressShared struct BlogListSiteView: View { diff --git a/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconViewModel+Extensions.swift similarity index 88% rename from WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconViewModel.swift rename to WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconViewModel+Extensions.swift index a6a98aeffeb5..aa49a5f98592 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Picker/BlogList/SiteIconViewModel+Extensions.swift @@ -2,44 +2,24 @@ import Foundation import SwiftUI import WordPressShared import WordPressKit -import WordPressMedia - -struct SiteIconViewModel { - var imageURL: URL? - var firstLetter: Character? - var size: Size - var host: MediaHost? - - enum Size { - case small - case regular - case large - - var width: CGFloat { - switch self { - case .small: 28 - case .regular: 40 - case .large: 72 - } - } - - var size: CGSize { - CGSize(width: width, height: width) - } - } +import AsyncImageKit +import WordPressUI +extension SiteIconViewModel { init(blog: Blog, size: Size = .regular) { - self.size = size + self.init(size: size) + self.firstLetter = blog.title?.first if blog.hasIcon, let icon = blog.icon { self.imageURL = SiteIconViewModel.optimizedURL(for: icon, imageSize: size.size, isP2: blog.isAutomatticP2) - self.host = MediaHost(with: blog) + self.host = MediaHost(blog) } } init(readerSiteTopic: ReaderSiteTopic, size: Size = .regular) { - self.size = size + self.init(size: size) + self.firstLetter = readerSiteTopic.title.first self.imageURL = SiteIconViewModel.makeReaderSiteIconURL( iconURL: readerSiteTopic.siteBlavatar, diff --git a/WordPress/Classes/ViewRelated/Cells/MediaItemHeaderView.swift b/WordPress/Classes/ViewRelated/Cells/MediaItemHeaderView.swift index 9c1d66b83e9b..3d54aabbda1c 100644 --- a/WordPress/Classes/ViewRelated/Cells/MediaItemHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Cells/MediaItemHeaderView.swift @@ -1,10 +1,10 @@ import UIKit import Gridicons import WordPressShared -import WordPressMedia +import AsyncImageKit final class MediaItemHeaderView: UIView { - private let imageView = CachedAnimatedImageView() + let imageView = AsyncImageView() private let errorView = UIImageView() private let videoIconView = PlayIconView() private let loadingIndicator = UIActivityIndicatorView(style: .large) @@ -103,13 +103,7 @@ final class MediaItemHeaderView: UIView { Task { let image = try? await MediaImageService.shared.image(for: media, size: .large) loadingIndicator.stopAnimating() - - if let gif = image as? AnimatedImage, let data = gif.gifData { - imageView.animate(withGIFData: data) - } else { - imageView.image = image - } - + imageView.image = image errorView.isHidden = image != nil } diff --git a/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.h b/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.h deleted file mode 100644 index ccbca15a7e47..000000000000 --- a/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.h +++ /dev/null @@ -1,21 +0,0 @@ -@import WordPressShared; - -@class AbstractPost; -@class PostFeaturedImageCell; - -@protocol PostFeaturedImageCellDelegate -- (void)postFeatureImageCellDidFinishLoadingImage:(nonnull PostFeaturedImageCell *)cell; -- (void)postFeatureImageCell:(nonnull PostFeaturedImageCell *)cell didFinishLoadingAnimatedImageWithData:(nullable NSData *)animationData; -- (void)postFeatureImageCell:(nonnull PostFeaturedImageCell *)cell didFinishLoadingImageWithError:(nullable NSError *)error; -@end - -@interface PostFeaturedImageCell : WPTableViewCell - -extern CGFloat const PostFeaturedImageCellMargin; - -@property (weak, nonatomic, nullable) id delegate; -@property (strong, nonatomic, readonly, nullable) UIImage *image; - -- (void)setImageWithURL:(nonnull NSURL *)url inPost:(nonnull AbstractPost *)post withSize:(CGSize)size; - -@end diff --git a/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.m b/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.m deleted file mode 100644 index 40331ca999b7..000000000000 --- a/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.m +++ /dev/null @@ -1,96 +0,0 @@ -#import "PostFeaturedImageCell.h" -#import "WordPress-Swift.h" - -CGFloat const PostFeaturedImageCellMargin = 15.0f; - -@interface PostFeaturedImageCell () - -@property (nonatomic, strong) CachedAnimatedImageView *featuredImageView; -@property (nonatomic, strong) ImageLoader *imageLoader; - -@end - -@implementation PostFeaturedImageCell - -- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier -{ - self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; - if (self) { - [self setup]; - } - return self; -} - -- (void)setup -{ - [self layoutImageView]; - _imageLoader = [[ImageLoader alloc] initWithImageView:self.featuredImageView gifStrategy:GIFStrategyLargeGIFs]; - self.accessibilityLabel = NSLocalizedString(@"A featured image is set. Tap to change it.", @"Label for image that is set as a feature image for post/page"); - self.accessibilityIdentifier = @"CurrentFeaturedImage"; -} - -- (void)setImageWithURL:(NSURL *)url inPost:(AbstractPost *)post withSize:(CGSize)size -{ - __weak PostFeaturedImageCell *weakSelf = self; - [self.imageLoader loadImageWithURL:url fromPost:post preferredSize:size placeholder:nil success:^{ - [weakSelf informDelegateImageLoaded]; - } error:^(NSError * _Nullable error) { - if (weakSelf && weakSelf.delegate) { - [weakSelf.delegate postFeatureImageCell:weakSelf didFinishLoadingImageWithError:error]; - } - }]; -} - -- (void)informDelegateImageLoaded -{ - if (self.delegate == nil) { - return; - } - - if (self.featuredImageView.animatedGifData) { - [self.delegate postFeatureImageCell:self didFinishLoadingAnimatedImageWithData:self.featuredImageView.animatedGifData]; - } else { - [self.delegate postFeatureImageCellDidFinishLoadingImage:self]; - } -} - -- (UIImage *)image -{ - return self.featuredImageView.image; -} - -- (void)prepareForReuse -{ - [super prepareForReuse]; - [self.featuredImageView prepForReuse]; -} - -#pragma mark - Helpers - -- (CachedAnimatedImageView *)featuredImageView -{ - if (!_featuredImageView) { - _featuredImageView = [[CachedAnimatedImageView alloc] init]; - _featuredImageView.contentMode = UIViewContentModeScaleAspectFill; - _featuredImageView.clipsToBounds = YES; - _featuredImageView.translatesAutoresizingMaskIntoConstraints = NO; - } - - return _featuredImageView; -} - -- (void)layoutImageView -{ - UIView *imageView = self.featuredImageView; - - [self.contentView addSubview:imageView]; - UILayoutGuide *readableGuide = self.contentView.readableContentGuide; - [NSLayoutConstraint activateConstraints:@[ - [imageView.leadingAnchor constraintEqualToAnchor:readableGuide.leadingAnchor], - [imageView.trailingAnchor constraintEqualToAnchor:readableGuide.trailingAnchor], - [imageView.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:PostFeaturedImageCellMargin], - [imageView.bottomAnchor constraintEqualToAnchor:self.contentView.bottomAnchor constant:-PostFeaturedImageCellMargin] - ]]; -} - -@end diff --git a/WordPress/Classes/ViewRelated/Cells/WPProgressTableViewCell.h b/WordPress/Classes/ViewRelated/Cells/WPProgressTableViewCell.h deleted file mode 100644 index d576ff22671f..000000000000 --- a/WordPress/Classes/ViewRelated/Cells/WPProgressTableViewCell.h +++ /dev/null @@ -1,15 +0,0 @@ -@import UIKit; -@import WordPressSharedObjC; - -@class WPTableViewCell; - -/** - The corresponding value is an UIImage instance representing the work being done - */ -extern NSProgressUserInfoKey const WPProgressImageThumbnailKey; - -@interface WPProgressTableViewCell : WPTableViewCell - -- (void) setProgress:(NSProgress *)progress; - -@end diff --git a/WordPress/Classes/ViewRelated/Cells/WPProgressTableViewCell.m b/WordPress/Classes/ViewRelated/Cells/WPProgressTableViewCell.m deleted file mode 100644 index 099920c77192..000000000000 --- a/WordPress/Classes/ViewRelated/Cells/WPProgressTableViewCell.m +++ /dev/null @@ -1,124 +0,0 @@ -#import "WPProgressTableViewCell.h" -#import "WordPress-Swift.h" - -static void *ProgressObserverContext = &ProgressObserverContext; - -NSProgressUserInfoKey const WPProgressImageThumbnailKey = @"WPProgressImageThumbnailKey"; -@interface WPProgressTableViewCell () - -@property (nonatomic, strong) StoppableProgressIndicatorView * progressView; - -@end - -@implementation WPProgressTableViewCell { - NSProgress *_progress; -} - -- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier -{ - self = [super initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:reuseIdentifier]; - if (self) { - _progressView = [[StoppableProgressIndicatorView alloc] initWithFrame:CGRectMake(10.0,0.0,40.0,40.0)]; - _progressView.hidden = YES; - self.accessoryView = _progressView; - } - return self; -} - -- (instancetype)initWithCoder:(NSCoder *)coder -{ - NSAssert(false, @"WPProgressTableViewCell can't be created using a nib"); - return [super initWithCoder:coder]; -} - -- (void)dealloc -{ - [_progress removeObserver:self forKeyPath:NSStringFromSelector(@selector(fractionCompleted))]; -} - -- (void)prepareForReuse -{ - [super prepareForReuse]; - [self setProgress:nil]; - self.progressView.hidden = YES; - self.progressView.hidesWhenStopped=YES; - [self.progressView stopAnimating]; -} - -- (void)layoutSubviews -{ - [super layoutSubviews]; - self.imageView.frame = CGRectInset(self.imageView.frame, 0.0, 5.0); -} - -#pragma mark - Progress handling - -- (void) setProgress:(NSProgress *) progress { - if (progress == _progress){ - return; - } - [_progress removeObserver:self forKeyPath:NSStringFromSelector(@selector(fractionCompleted))]; - - _progress = progress; - - [_progress addObserver:self - forKeyPath:NSStringFromSelector(@selector(fractionCompleted)) - options:NSKeyValueObservingOptionInitial - context:ProgressObserverContext]; - - if (_progress.isCancellable){ - [self.progressView.stopButton addTarget:self action:@selector(stopPressed:) forControlEvents:UIControlEventTouchUpInside]; - } - [self updateProgress]; -} - -- (void)updateProgress -{ - if (_progress.fractionCompleted < 1 && !_progress.isCancelled) { - [_progressView startAnimating]; - } else { - [_progressView stopAnimating]; - } - - _progressView.mayStop = _progress.isCancellable; - if ([_progress isCancelled]) { - self.textLabel.text = NSLocalizedString(@"Canceled", @"The action was canceled"); - self.detailTextLabel.text = @""; - } else if ((_progress.totalUnitCount == 0 && _progress.completedUnitCount == 0) || _progress.userInfo[@"mediaError"] ) { - self.textLabel.text = NSLocalizedString(@"Failed", @"The action failed"); - self.detailTextLabel.text = @""; - } else if (_progress.fractionCompleted >= 1) { - self.textLabel.text = NSLocalizedString(@"Completed", @"The action is completed"); - self.detailTextLabel.text = @""; - } else { - self.textLabel.text = [_progress localizedDescription]; - self.detailTextLabel.text = [_progress localizedAdditionalDescription]; - } - [self.imageView setImage:_progress.userInfo[WPProgressImageThumbnailKey]]; -} - -#pragma mark - KVO - -- (void)observeValueForKeyPath:(NSString *)keyPath - ofObject:(id)object - change:(NSDictionary *)change - context:(void *)context -{ - if (context == ProgressObserverContext && object == _progress) { - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - [self updateProgress]; - }]; - } else { - [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; - } -} - -#pragma mark - Stop Button events - -- (void) stopPressed:(id)sender -{ - [_progress cancel]; - [self updateProgress]; -} - -@end diff --git a/WordPress/Classes/ViewRelated/Cells/WPTableViewActivityCell.h b/WordPress/Classes/ViewRelated/Cells/WPTableViewActivityCell.h deleted file mode 100644 index 5664d394f281..000000000000 --- a/WordPress/Classes/ViewRelated/Cells/WPTableViewActivityCell.h +++ /dev/null @@ -1,9 +0,0 @@ -@import UIKit; -@import WordPressShared; - -@interface WPTableViewActivityCell : WPTableViewCell {} - -@property (nonatomic, strong) IBOutlet UIActivityIndicatorView *spinner; -@property (nonatomic, strong) IBOutlet UIView *viewForBackground; - -@end diff --git a/WordPress/Classes/ViewRelated/Cells/WPTableViewActivityCell.m b/WordPress/Classes/ViewRelated/Cells/WPTableViewActivityCell.m deleted file mode 100644 index f93b933c2def..000000000000 --- a/WordPress/Classes/ViewRelated/Cells/WPTableViewActivityCell.m +++ /dev/null @@ -1,5 +0,0 @@ -#import "WPTableViewActivityCell.h" - -@implementation WPTableViewActivityCell - -@end diff --git a/WordPress/Classes/ViewRelated/Cells/WPTableViewActivityCell.xib b/WordPress/Classes/ViewRelated/Cells/WPTableViewActivityCell.xib deleted file mode 100644 index 0688316dcecf..000000000000 --- a/WordPress/Classes/ViewRelated/Cells/WPTableViewActivityCell.xib +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift index 7b0a4c918050..679cfb7551e6 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift @@ -1068,13 +1068,15 @@ private extension CommentDetailViewController { return } + replyTextView?.setShowingLoadingIndicator(true) + commentService.createReply(for: comment, content: content) { reply in self.commentService.uploadComment(reply, success: { [weak self] in - self?.displayReplyNotice(success: true) + self?.didSendReply(success: true) self?.refreshCommentReplyIfNeeded() }, failure: { [weak self] error in DDLogError("Failed uploading comment reply: \(String(describing: error))") - self?.displayReplyNotice(success: false) + self?.didSendReply(success: false, error: error) }) } } @@ -1084,21 +1086,30 @@ private extension CommentDetailViewController { return } + replyTextView?.setShowingLoadingIndicator(true) + commentService.replyToHierarchicalComment(withID: NSNumber(value: comment.commentID), post: post, content: content, success: { [weak self] in - self?.displayReplyNotice(success: true) + self?.didSendReply(success: true) self?.refreshCommentReplyIfNeeded() }, failure: { [weak self] error in DDLogError("Failed creating post comment reply: \(String(describing: error))") - self?.displayReplyNotice(success: false) + self?.didSendReply(success: false, error: error) }) } - func displayReplyNotice(success: Bool) { + func didSendReply(success: Bool, error: Error? = nil) { + replyTextView?.setShowingLoadingIndicator(false) + if success { + replyTextView?.text = "" + } else { + replyTextView?.becomeFirstResponder() + } + let message = success ? ReplyMessages.successMessage : ReplyMessages.failureMessage - displayNotice(title: message) + displayNotice(title: message, message: error?.localizedDescription) } func configureSuggestionsView() { diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentsViewController.m b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentsViewController.m index fd7bca9f6dad..b28450406825 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentsViewController.m +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentsViewController.m @@ -552,12 +552,6 @@ - (BOOL)contentIsEmpty - (void)refreshAndSyncWithInteraction { - if (!ReachabilityUtils.isInternetReachable) { - [self refreshPullToRefresh]; - [self refreshNoConnectionView]; - return; - } - [self.syncHelper syncContentWithUserInteraction]; } diff --git a/WordPress/Classes/ViewRelated/Comments/Views/Detail/CommentContentTableViewCell.swift b/WordPress/Classes/ViewRelated/Comments/Views/Detail/CommentContentTableViewCell.swift index 50e721c35868..89acdcacf975 100644 --- a/WordPress/Classes/ViewRelated/Comments/Views/Detail/CommentContentTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Comments/Views/Detail/CommentContentTableViewCell.swift @@ -81,7 +81,7 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { @objc var isReplyHighlighted: Bool = false { didSet { - replyButton.tintColor = isReplyHighlighted ? UIAppColor.brand : .label + replyButton.tintColor = isReplyHighlighted ? UIAppColor.primary : .label replyButton.configuration?.image = UIImage(systemName: isReplyHighlighted ? "arrowshape.turn.up.left.fill" : "arrowshape.turn.up.left") } } diff --git a/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/RichCommentContentRenderer.swift b/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/RichCommentContentRenderer.swift index 11bdb6bae598..1fb14ad14e7e 100644 --- a/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/RichCommentContentRenderer.swift +++ b/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/RichCommentContentRenderer.swift @@ -1,5 +1,5 @@ import UIKit -import WordPressMedia +import AsyncImageKit /// Renders the comment body through `WPRichContentView`. /// @@ -74,12 +74,9 @@ private extension RichCommentContentRenderer { var mediaHost: MediaHost { if let blog = comment.blog { - return MediaHost(with: blog, failure: { error in - // We'll log the error, so we know it's there, but we won't halt execution. - WordPressAppDelegate.crashLogging?.logError(error) - }) + return MediaHost(blog) } else if let post = comment.post as? ReaderPost, post.isBlogPrivate { - return MediaHost(with: post) + return MediaHost(post) } return .publicSite diff --git a/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController.swift index c0c3d5b69f8d..25fc91de5f33 100644 --- a/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController.swift +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController.swift @@ -117,16 +117,12 @@ class RegisterDomainDetailsViewController: UITableViewController { } private func showAlert(title: String? = nil, message: String) { - let alertCancel = NSLocalizedString( - "OK", - comment: "Title of an OK button. Pressing the button acknowledges and dismisses a prompt." - ) let alertController = UIAlertController( title: title, message: message, preferredStyle: .alert ) - alertController.addCancelActionWithTitle(alertCancel, handler: nil) + alertController.addCancelActionWithTitle(SharedStrings.Button.ok, handler: nil) present(alertController, animated: true, completion: nil) } diff --git a/WordPress/Classes/ViewRelated/Domains/Views/SiteDomainsView.swift b/WordPress/Classes/ViewRelated/Domains/Views/SiteDomainsView.swift index f4b5fea218f1..6ba2c30724e2 100644 --- a/WordPress/Classes/ViewRelated/Domains/Views/SiteDomainsView.swift +++ b/WordPress/Classes/ViewRelated/Domains/Views/SiteDomainsView.swift @@ -89,7 +89,7 @@ struct SiteDomainsView: View { Button(action: { showDetails(for: navigation) }) { HStack(alignment: .center) { AllDomainsListCardView(viewModel: row.viewModel, padding: 0) - Image(systemName: "chevron.right") + Image(systemName: "chevron.forward") .font(.subheadline.weight(.medium)) .foregroundColor(.secondary.opacity(0.5)) } @@ -130,7 +130,7 @@ struct SiteDomainsView: View { } label: { Text(TextContent.additionalDomainTitle(blog.canRegisterDomainWithPaidPlan)) .style(TextStyle.bodyMedium(.regular)) - .foregroundColor(AppColor.brand) + .foregroundColor(AppColor.primary) } } } diff --git a/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopover.swift b/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopover.swift index 658d4bab2d85..0eb19455c14a 100644 --- a/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopover.swift +++ b/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopover.swift @@ -1,21 +1,28 @@ import SwiftUI import JetpackStatsWidgetsCore -import DesignSystem struct CompliancePopover: View { @StateObject var viewModel: CompliancePopoverViewModel var body: some View { - VStack(alignment: .leading, spacing: .DS.Padding.double) { - titleText - subtitleText - analyticsToggle - footnote - buttonsHStack + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 8) { + titleText.padding(.top, 16) + subtitleText + analyticsToggle.padding(.top, 8) + footnote + } + .padding(20) + } + .safeAreaInset(edge: .bottom) { + HStack(spacing: 8) { + settingsButton + saveButton + } + .padding(20) + .background(Color(.systemBackground)) } - .padding(.DS.Padding.medium) - .fixedSize(horizontal: false, vertical: true) } private var titleText: some View { @@ -33,7 +40,7 @@ struct CompliancePopover: View { Toggle(Strings.toggleTitle, isOn: $viewModel.isAnalyticsEnabled) .foregroundStyle(Color(.label)) .toggleStyle(UIAppColor.switchStyle) - .padding(.vertical, .DS.Padding.single) + .padding(.vertical, 8) } private var footnote: some View { @@ -42,26 +49,19 @@ struct CompliancePopover: View { .foregroundColor(.secondary) } - private var buttonsHStack: some View { - HStack(spacing: .DS.Padding.single) { - settingsButton - saveButton - }.padding(.top, .DS.Padding.medium) - } - private var settingsButton: some View { Button(action: { self.viewModel.didTapSettings() }) { ZStack { - RoundedRectangle(cornerRadius: .DS.Padding.single) - .stroke(.gray, lineWidth: .DS.Border.thin) + RoundedRectangle(cornerRadius: 8) + .stroke(.gray, lineWidth: 0.5) Text(Strings.settingsButtonTitle) .font(.body) } } - .foregroundColor(AppColor.brand) - .frame(height: .DS.Hitbox.minTappableLength) + .foregroundColor(AppColor.primary) + .frame(height: 44) } private var saveButton: some View { @@ -70,13 +70,13 @@ struct CompliancePopover: View { }) { ZStack { RoundedRectangle(cornerRadius: 8) - .fill(AppColor.brand) + .fill(AppColor.primary) Text(Strings.saveButtonTitle) .font(.body) } } .foregroundColor(.white) - .frame(height: .DS.Hitbox.minTappableLength) + .frame(height: 44) } } diff --git a/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverCoordinator.swift b/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverCoordinator.swift index 5d839056ee74..69aa565667fb 100644 --- a/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverCoordinator.swift +++ b/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverCoordinator.swift @@ -101,10 +101,10 @@ final class CompliancePopoverCoordinator: CompliancePopoverCoordinatorProtocol { contextManager: ContextManager.shared ) complianceViewModel.coordinator = self - let complianceViewController = CompliancePopoverViewController(viewModel: complianceViewModel) - let bottomSheetViewController = BottomSheetViewController(childViewController: complianceViewController, customHeaderSpacing: 0) - - bottomSheetViewController.show(from: presentingViewController) + let complianceVC = CompliancePopoverViewController(viewModel: complianceViewModel) + complianceVC.sheetPresentationController?.detents = [.medium(), .large()] + complianceVC.isModalInPresentation = true + presentingViewController.present(complianceVC, animated: true) } } diff --git a/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverViewController.swift b/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverViewController.swift index eaf6676ecc6f..151de0f31f6f 100644 --- a/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverViewController.swift +++ b/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverViewController.swift @@ -2,34 +2,12 @@ import UIKit import SwiftUI import WordPressUI -final class CompliancePopoverViewController: UIViewController { - - // MARK: - Dependencies - +final class CompliancePopoverViewController: UIHostingController { private let viewModel: CompliancePopoverViewModel - // MARK: - Views - - private let scrollView: UIScrollView = { - let view = UIScrollView() - view.showsVerticalScrollIndicator = false - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - private let hostingController: UIHostingController - - private var contentView: UIView { - return hostingController.view - } - - // MARK: - Init - init(viewModel: CompliancePopoverViewModel) { self.viewModel = viewModel - let content = CompliancePopover(viewModel: viewModel) - self.hostingController = UIHostingController(rootView: content) - super.init(nibName: nil, bundle: nil) + super.init(rootView: CompliancePopover(viewModel: viewModel)) } required dynamic init?(coder aDecoder: NSCoder) { @@ -40,60 +18,7 @@ final class CompliancePopoverViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - self.addContentView() - self.viewModel.didDisplayPopover() - } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - // Calculate the size needed for the view to fit its content - let targetSize = CGSize(width: view.bounds.width, height: 0) - self.contentView.frame = CGRect(origin: .zero, size: targetSize) - let contentViewSize = contentView.systemLayoutSizeFitting(targetSize) - self.contentView.frame.size = contentViewSize - - // Set the scrollView's content size to match the contentView's size - // - // Scroll is enabled / disabled automatically depending on whether the `contentSize` is bigger than the its size. - self.scrollView.contentSize = contentViewSize - - // Set the preferred content size for the view controller to match the contentView's size - // - // This property should be updated when `DrawerPresentable.collapsedHeight` is `intrinsicHeight`. - // Because under the hood the `BottomSheetViewController` reads this property to layout its subviews. - self.preferredContentSize = contentViewSize - } - - private func addContentView() { - self.view.addSubview(scrollView) - self.view.pinSubviewToAllEdges(scrollView) - self.hostingController.willMove(toParent: self) - self.addChild(hostingController) - self.contentView.translatesAutoresizingMaskIntoConstraints = true - self.scrollView.addSubview(contentView) - self.hostingController.didMove(toParent: self) - } -} - -// MARK: - DrawerPresentable - -extension CompliancePopoverViewController: DrawerPresentable { - var collapsedHeight: DrawerHeight { - if traitCollection.verticalSizeClass == .compact { - return .maxHeight - } - return .intrinsicHeight - } - - var allowsUserTransition: Bool { - return false - } - - var allowsDragToDismiss: Bool { - false - } - - var allowsTapToDismiss: Bool { - return false + self.viewModel.didDisplayPopover() } } diff --git a/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverViewModel.swift b/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverViewModel.swift index 9599596f042d..c7b15044b923 100644 --- a/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverViewModel.swift +++ b/WordPress/Classes/ViewRelated/EEUUSCompliance/CompliancePopoverViewModel.swift @@ -45,13 +45,10 @@ class CompliancePopoverViewModel: ObservableObject { let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) return (account?.userID, account?.wordPressComRestApi) } - - guard let accountID, let restAPI else { - return + if let accountID, let restAPI { + let change = AccountSettingsChange.tracksOptOut(!isAnalyticsEnabled) + AccountSettingsService(userID: accountID.intValue, api: restAPI).saveChange(change) } - - let change = AccountSettingsChange.tracksOptOut(!isAnalyticsEnabled) - AccountSettingsService(userID: accountID.intValue, api: restAPI).saveChange(change) coordinator?.dismiss() defaults.didShowCompliancePopup = true analyticsTracker.trackPrivacyChoicesBannerSaveButtonTapped(analyticsEnabled: isAnalyticsEnabled) diff --git a/WordPress/Classes/ViewRelated/Gravatar/UIImageView+Gravatar.swift b/WordPress/Classes/ViewRelated/Gravatar/UIImageView+Gravatar.swift index 9c61bdf2b8d0..694c530778b7 100644 --- a/WordPress/Classes/ViewRelated/Gravatar/UIImageView+Gravatar.swift +++ b/WordPress/Classes/ViewRelated/Gravatar/UIImageView+Gravatar.swift @@ -1,4 +1,5 @@ import Foundation +import AsyncImageKit import GravatarUI import WordPressUI @@ -114,10 +115,12 @@ fileprivate struct GravatarDefaults { extension AvatarURL { - public static func url(for email: String, - preferredSize: ImageSize? = nil, - gravatarRating: Rating? = nil, - defaultAvatarOption: DefaultAvatarOption? = .status404) -> URL? { + public static func url( + for email: String, + preferredSize: Gravatar.ImageSize? = nil, + gravatarRating: Rating? = nil, + defaultAvatarOption: DefaultAvatarOption? = .status404 + ) -> URL? { AvatarURL( with: .email(email), // Passing GravatarDefaults.imageSize to keep the previous default. diff --git a/WordPress/Classes/ViewRelated/Gutenberg/AztecAttachmentDelegate.swift b/WordPress/Classes/ViewRelated/Gutenberg/AztecAttachmentDelegate.swift index 995bd75deea4..37a03f3edd8e 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/AztecAttachmentDelegate.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/AztecAttachmentDelegate.swift @@ -1,5 +1,5 @@ import Aztec -import WordPressMedia +import AsyncImageKit class AztecAttachmentDelegate: TextViewAttachmentDelegate { private let post: AbstractPost diff --git a/WordPress/Classes/ViewRelated/Gutenberg/EditorMediaUtility.swift b/WordPress/Classes/ViewRelated/Gutenberg/EditorMediaUtility.swift index c740486437b5..db8140d79a04 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/EditorMediaUtility.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/EditorMediaUtility.swift @@ -2,61 +2,7 @@ import AutomatticTracks import Aztec import Gridicons import WordPressShared -import WordPressMedia - -final class AuthenticatedImageDownload: AsyncOperation, @unchecked Sendable { - enum DownloadError: Error { - case blogNotFound - } - - let url: URL - let mediaHost: MediaHost - private let callbackQueue: DispatchQueue - private let onSuccess: (UIImage) -> () - private let onFailure: (Error) -> () - - init(url: URL, mediaHost: MediaHost, callbackQueue: DispatchQueue, onSuccess: @escaping (UIImage) -> (), onFailure: @escaping (Error) -> ()) { - self.url = url - self.mediaHost = mediaHost - self.callbackQueue = callbackQueue - self.onSuccess = onSuccess - self.onFailure = onFailure - } - - override func main() { - let mediaRequestAuthenticator = MediaRequestAuthenticator() - mediaRequestAuthenticator.authenticatedRequest( - for: url, - from: mediaHost, - onComplete: { request in - ImageDownloader.shared.downloadImage(for: request) { (image, error) in - self.state = .isFinished - - self.callbackQueue.async { - guard let image else { - DDLogError("Unable to download image for attachment with url = \(String(describing: request.url)). Details: \(String(describing: error?.localizedDescription))") - if let error { - self.onFailure(error) - } else { - self.onFailure(NSError(domain: NSURLErrorDomain, code: NSURLErrorUnknown, userInfo: nil)) - } - - return - } - - self.onSuccess(image) - } - } - }, - onFailure: { error in - self.state = .isFinished - self.callbackQueue.async { - self.onFailure(error) - } - } - ) - } -} +import AsyncImageKit class EditorMediaUtility { private static let InternalInconsistencyError = NSError(domain: NSExceptionName.internalInconsistencyException.rawValue, code: 0) @@ -65,6 +11,10 @@ class EditorMediaUtility { static let placeholderDocumentLink = URL(string: "documentUploading://")! } + enum DownloadError: Error { + case blogNotFound + } + func placeholderImage(for attachment: NSTextAttachment, size: CGSize, tintColor: UIColor?) -> UIImage { var icon: UIImage switch attachment { @@ -137,17 +87,18 @@ class EditorMediaUtility { callbackQueue.async { failure(error) } - return EmptyImageDownloaderTask() - case let .success((requestURL, mediaHost)): - let imageDownload = AuthenticatedImageDownload( - url: requestURL, - mediaHost: mediaHost, - callbackQueue: callbackQueue, - onSuccess: success, - onFailure: failure - ) - imageDownload.start() - return imageDownload + return MeediaUtilityTask { /* do nothing */ } + case let .success((imageURL, host)): + let task = Task { @MainActor in + do { + let image = try await ImageDownloader.shared.image(from: imageURL, host: host) + success(image) + } catch { + failure(error) + + } + } + return MeediaUtilityTask { task.cancel() } } } @@ -160,7 +111,7 @@ class EditorMediaUtility { ) throws -> (URL, MediaHost) { // This function is added to debug the issue linked below. let safeExistingObject: (NSManagedObjectID) throws -> NSManagedObject = { objectID in - var object: Result = .failure(AuthenticatedImageDownload.DownloadError.blogNotFound) + var object: Result = .failure(DownloadError.blogNotFound) do { // Catch an Objective-C `NSInvalidArgumentException` exception from `existingObject(with:)`. // See https://github.com/wordpress-mobile/WordPress-iOS/issues/20630 @@ -199,7 +150,7 @@ class EditorMediaUtility { requestURL = PhotonImageURLHelper.photonURL(with: size, forImageURL: url) } - return (requestURL, MediaHost(with: post.blog)) + return (requestURL, MediaHost(post.blog)) } static func fetchRemoteVideoURL(for media: Media, in post: AbstractPost, withToken: Bool = false, completion: @escaping ( Result<(URL), Error> ) -> Void) { @@ -251,8 +202,10 @@ class EditorMediaUtility { } } -private class EmptyImageDownloaderTask: ImageDownloaderTask { +private struct MeediaUtilityTask: ImageDownloaderTask { + let closure: @Sendable () -> Void + func cancel() { - // Do nothing + closure() } } diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergImageLoader.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergImageLoader.swift index a0ce0202c7c6..2a1b46e5f9c6 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergImageLoader.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergImageLoader.swift @@ -19,12 +19,6 @@ class GutenbergImageLoader: NSObject, RCTImageURLLoader { } func loadImage(for imageURL: URL, size: CGSize, scale: CGFloat, resizeMode: RCTResizeMode, progressHandler: RCTImageLoaderProgressBlock, partialLoadHandler: RCTImageLoaderPartialLoadBlock, completionHandler: @escaping RCTImageLoaderCompletionBlock) -> RCTImageLoaderCancellationBlock? { - let cacheKey = getCacheKey(for: imageURL, size: size) - - if let image = AnimatedImageCache.shared.cachedStaticImage(url: cacheKey) { - completionHandler(nil, image) - return {} - } var finalSize = size var finalScale = scale @@ -36,7 +30,6 @@ class GutenbergImageLoader: NSObject, RCTImageURLLoader { } let task = mediaUtility.downloadImage(from: imageURL, size: finalSize, scale: finalScale, post: post, success: { (image) in - AnimatedImageCache.shared.cacheStaticImage(url: cacheKey, image: image) completionHandler(nil, image) }, onFailure: { (error) in completionHandler(error, nil) @@ -56,17 +49,6 @@ class GutenbergImageLoader: NSObject, RCTImageURLLoader { return nil } - private func getCacheKey(for url: URL, size: CGSize) -> URL? { - guard size != CGSize.zero else { - return url - } - var components = URLComponents(url: url, resolvingAgainstBaseURL: true) - let queryItems = components?.queryItems - let newQueryItems = (queryItems ?? []) + [URLQueryItem(name: "cachekey", value: "\(size)")] - components?.queryItems = newQueryItems - return components?.url - } - static func moduleName() -> String! { return String(describing: self) } diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift index 8d886b989925..bf503b28f924 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift @@ -1,8 +1,10 @@ import UIKit +import AsyncImageKit import Gutenberg import Aztec import WordPressFlux import WordPressShared +import WordPressUI import React import AutomatticTracks import Combine @@ -90,7 +92,7 @@ class GutenbergViewController: UIViewController, PostEditor, FeaturedImageDelega var editorSession: PostEditorAnalyticsSession - var onClose: ((Bool) -> Void)? + var onClose: (() -> Void)? var postIsReblogged: Bool = false @@ -897,19 +899,9 @@ extension GutenbergViewController: GutenbergBridgeDelegate { } func gutenbergDidRequestImagePreview(with fullSizeUrl: URL, thumbUrl: URL?) { - navigationController?.definesPresentationContext = true - - let controller: WPImageViewController - if let image = AnimatedImageCache.shared.cachedStaticImage(url: fullSizeUrl) { - controller = WPImageViewController(image: image) - } else { - controller = WPImageViewController(externalMediaURL: fullSizeUrl) - } - - controller.post = self.post - controller.modalTransitionStyle = .crossDissolve - controller.modalPresentationStyle = .overCurrentContext - self.present(controller, animated: true) + let lightboxVC = LightboxViewController(sourceURL: fullSizeUrl, host: MediaHost(post)) + lightboxVC.configureZoomTransition() + present(lightboxVC, animated: true) } func gutenbergDidRequestUnsupportedBlockFallback(for block: Block) { diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergMediaEditorImage.swift b/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergMediaEditorImage.swift index 9f4b34ae79f3..53558805f90a 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergMediaEditorImage.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergMediaEditorImage.swift @@ -1,6 +1,6 @@ import Foundation import MediaEditor -import WordPressMedia +import AsyncImageKit /** This is a struct to be given to MediaEditor that represent the image. @@ -32,7 +32,7 @@ class GutenbergMediaEditorImage: AsyncImage { init(url: URL, post: AbstractPost) { originalURL = url self.post = post - thumb = AnimatedImageCache.shared.cachedStaticImage(url: originalURL) + thumb = ImageDownloader.shared.cachedImage(for: originalURL) } /** diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackBrandingCoordinator.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackBrandingCoordinator.swift index dd59b175b96c..1227814ac5c9 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackBrandingCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackBrandingCoordinator.swift @@ -4,7 +4,7 @@ import WordPressUI /// A class containing convenience methods for the the Jetpack branding experience class JetpackBrandingCoordinator { - static func presentOverlay(from viewController: UIViewController, redirectAction: (() -> Void)? = nil) { + static func presentOverlay(from presentingViewController: UIViewController, redirectAction: (() -> Void)? = nil) { let action = redirectAction ?? { // Try to export WordPress data to a shared location before redirecting the user. @@ -13,9 +13,9 @@ class JetpackBrandingCoordinator { } } - let jetpackOverlayViewController = JetpackOverlayViewController(viewFactory: makeJetpackOverlayView, redirectAction: action) - let bottomSheet = BottomSheetViewController(childViewController: jetpackOverlayViewController, customHeaderSpacing: 0) - bottomSheet.show(from: viewController) + let jetpackOverlayVC = JetpackOverlayViewController(viewFactory: makeJetpackOverlayView, redirectAction: action) + jetpackOverlayVC.sheetPresentationController?.detents = [.medium()] + presentingViewController.present(jetpackOverlayVC, animated: true) } static func makeJetpackOverlayView(redirectAction: (() -> Void)? = nil) -> UIView { diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayView.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayView.swift index 7a44c82bc646..5729457435c5 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayView.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayView.swift @@ -1,7 +1,8 @@ import Lottie import UIKit +import WordPressUI -class JetpackOverlayView: UIView { +final class JetpackOverlayView: UIView { private var buttonAction: (() -> Void)? @@ -38,7 +39,7 @@ class JetpackOverlayView: UIView { }() private lazy var stackView: UIStackView = { - let stackView = UIStackView(arrangedSubviews: [animationContainerView, titleLabel, descriptionLabel, getJetpackButton]) + let stackView = UIStackView(arrangedSubviews: [animationContainerView, titleLabel, descriptionLabel, SpacerView(minHeight: 8), getJetpackButton]) stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical stackView.alignment = .leading @@ -85,13 +86,11 @@ class JetpackOverlayView: UIView { }() private lazy var getJetpackButton: UIButton = { - let button = UIButton() - button.backgroundColor = UIAppColor.jetpackGreen(.shade40) - button.setTitle(TextContent.buttonTitle, for: .normal) - button.titleLabel?.adjustsFontSizeToFitWidth = true + var configuration = UIButton.Configuration.primary() + configuration.title = TextContent.buttonTitle + + let button = UIButton(configuration: configuration, primaryAction: nil) button.titleLabel?.adjustsFontForContentSizeCategory = true - button.layer.cornerRadius = Metrics.tryJetpackButtonCornerRadius - button.layer.cornerCurve = .continuous return button }() @@ -137,24 +136,16 @@ class JetpackOverlayView: UIView { private func configureConstraints() { animationContainerView.pinSubviewToAllEdges(animationView) - let stackViewTrailingConstraint = stackView.trailingAnchor.constraint(equalTo: trailingAnchor, - constant: -Metrics.edgeMargins.right) - stackViewTrailingConstraint.priority = Metrics.veryHighPriority - let stackViewBottomConstraint = stackView.bottomAnchor.constraint(lessThanOrEqualTo: safeBottomAnchor, - constant: -Metrics.edgeMargins.bottom) - stackViewBottomConstraint.priority = Metrics.veryHighPriority - NSLayoutConstraint.activate([ dismissButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.dismissButtonTrailingPadding), dismissButton.topAnchor.constraint(equalTo: topAnchor, constant: Metrics.dismissButtonTopPadding), dismissButton.heightAnchor.constraint(equalToConstant: Metrics.dismissButtonSize), dismissButton.widthAnchor.constraint(equalToConstant: Metrics.dismissButtonSize), stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Metrics.edgeMargins.left), - stackViewTrailingConstraint, + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.edgeMargins.right).withPriority(999), stackView.topAnchor.constraint(equalTo: dismissButton.bottomAnchor), - stackViewBottomConstraint, + stackView.bottomAnchor.constraint(lessThanOrEqualTo: safeBottomAnchor, constant: -Metrics.edgeMargins.bottom).withPriority(999), - getJetpackButton.heightAnchor.constraint(equalToConstant: Metrics.tryJetpackButtonHeight), getJetpackButton.widthAnchor.constraint(equalTo: stackView.widthAnchor), ]) } @@ -174,7 +165,7 @@ private extension JetpackOverlayView { static let imageToTitleSpacing: CGFloat = 24 static let titleToDescriptionSpacing: CGFloat = 10 static let descriptionToButtonSpacing: CGFloat = 40 - static let edgeMargins = UIEdgeInsets(top: 46, left: 30, bottom: 20, right: 30) + static let edgeMargins = UIEdgeInsets(top: 46, left: 20, bottom: 10, right: 20) // dismiss button static let dismissButtonTopPadding: CGFloat = 10 // takes into account the gripper static let dismissButtonTrailingPadding: CGFloat = 20 @@ -202,24 +193,25 @@ private extension JetpackOverlayView { let font = UIFont(descriptor: fontDescriptor, size: min(fontDescriptor.pointSize, maximumFontSize)) return UIFontMetrics.default.scaledFont(for: font, maximumPointSize: maximumFontSize) } - // "Try Jetpack" button - static let tryJetpackButtonHeight: CGFloat = 44 - static let tryJetpackButtonCornerRadius: CGFloat = 6 - // constraints - static let veryHighPriority = UILayoutPriority(rawValue: 999) } enum TextContent { - static let title = NSLocalizedString("jetpack.branding.overlay.title", - value: "WordPress is better with Jetpack", - comment: "Title of the Jetpack powered overlay.") + static let title = NSLocalizedString( + "jetpack.branding.overlay.title", + value: "WordPress is better with Jetpack", + comment: "Title of the Jetpack powered overlay." + ) - static let description = NSLocalizedString("jetpack.branding.overlay.description", - value: "The new Jetpack app has Stats, Reader, Notifications, and more that make your WordPress better.", - comment: "Description of the Jetpack powered overlay.") + static let description = NSLocalizedString( + "jetpack.branding.overlay.description", + value: "The new Jetpack app has Stats, Reader, Notifications, and more that make your WordPress better.", + comment: "Description of the Jetpack powered overlay." + ) - static let buttonTitle = NSLocalizedString("jetpack.branding.overlay.button.title", - value: "Try the new Jetpack app", - comment: "Button title of the Jetpack powered overlay.") + static let buttonTitle = NSLocalizedString( + "jetpack.branding.overlay.button.title", + value: "Try the new Jetpack app", + comment: "Button title of the Jetpack powered overlay." + ) } } diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayViewController.swift index 3f17f95d9a15..e61f94333462 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayViewController.swift @@ -37,23 +37,3 @@ class JetpackOverlayViewController: UIViewController { view.setNeedsLayout() } } - -extension JetpackOverlayViewController: DrawerPresentable { - var collapsedHeight: DrawerHeight { - .intrinsicHeight - } - - var allowsUserTransition: Bool { - false - } - - var compactWidth: DrawerWidth { - .maxWidth - } -} - -extension JetpackOverlayViewController: ChildDrawerPositionable { - var preferredDrawerPosition: DrawerPosition { - .collapsed - } -} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallStateView.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallStateView.swift index d1c67eb7cff5..73256c2b594f 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallStateView.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallStateView.swift @@ -128,7 +128,7 @@ private extension JetpackRemoteInstallStateView { } struct MainButton { - static let normalBackground = UIImage.renderBackgroundImage(fill: UIAppColor.brand) + static let normalBackground = UIImage.renderBackgroundImage(fill: UIAppColor.primary) static let loadingBackground = UIImage.renderBackgroundImage(fill: UIAppColor.jetpackGreen(.shade70)) static let titleColor = UIColor.white static let font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) @@ -136,7 +136,7 @@ private extension JetpackRemoteInstallStateView { } struct SupportButton { - static let color = UIAppColor.brand + static let color = UIAppColor.primary static let font = WPStyleGuide.fontForTextStyle(.body) static let text = NSLocalizedString("Contact Support", comment: "Contact Support button title") } diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackRestoreCompleteViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackRestoreCompleteViewController.swift index afb44f0f80a1..7075dbfcd052 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackRestoreCompleteViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackRestoreCompleteViewController.swift @@ -54,7 +54,7 @@ class JetpackRestoreCompleteViewController: BaseRestoreCompleteViewController { } let webVC = WebViewControllerFactory.controller(url: homeURL, source: "jetpack_restore_complete") - let navigationVC = LightNavigationController(rootViewController: webVC) + let navigationVC = UINavigationController(rootViewController: webVC) self.present(navigationVC, animated: true) } diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatDetailsViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatDetailsViewController.swift index 543ee7952759..5b694b1cf27f 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatDetailsViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatDetailsViewController.swift @@ -82,8 +82,8 @@ class JetpackScanThreatDetailsViewController: UIViewController { message: viewModel.fixDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: Strings.cancel, style: .cancel)) - alert.addAction(UIAlertAction(title: Strings.ok, style: .default, handler: { [weak self] _ in + alert.addAction(UIAlertAction(title: SharedStrings.Button.cancel, style: .cancel)) + alert.addAction(UIAlertAction(title: SharedStrings.Button.ok, style: .default, handler: { [weak self] _ in guard let self else { return } @@ -105,8 +105,8 @@ class JetpackScanThreatDetailsViewController: UIViewController { message: String(format: viewModel.ignoreActionMessage, blogName), preferredStyle: .alert) - alert.addAction(UIAlertAction(title: Strings.cancel, style: .cancel)) - alert.addAction(UIAlertAction(title: Strings.ok, style: .default, handler: { [weak self] _ in + alert.addAction(UIAlertAction(title: SharedStrings.Button.cancel, style: .cancel)) + alert.addAction(UIAlertAction(title: SharedStrings.Button.ok, style: .default, handler: { [weak self] _ in guard let self else { return } @@ -274,8 +274,6 @@ extension JetpackScanThreatDetailsViewController { private enum Strings { static let title = NSLocalizedString("Threat details", comment: "Title for the Jetpack Scan Threat Details screen") - static let ok = NSLocalizedString("OK", comment: "OK button for alert") - static let cancel = NSLocalizedString("Cancel", comment: "Cancel button for alert") static let jetpackSettingsNotice = NSLocalizedString("Unable to visit Jetpack settings for site", comment: "Message displayed when visiting the Jetpack settings page fails.") } } diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSpeedUpSiteSettingsViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSpeedUpSiteSettingsViewController.swift index 38741318ab74..b15d4f330b2d 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSpeedUpSiteSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSpeedUpSiteSettingsViewController.swift @@ -24,6 +24,7 @@ open class JetpackSpeedUpSiteSettingsViewController: UITableViewController { @objc public convenience init(blog: Blog) { self.init(style: .insetGrouped) + self.blog = blog self.service = BlogJetpackSettingsService(coreDataStack: ContextManager.shared) } @@ -32,6 +33,7 @@ open class JetpackSpeedUpSiteSettingsViewController: UITableViewController { open override func viewDidLoad() { super.viewDidLoad() + title = NSLocalizedString("Speed up your site", comment: "Title for the Speed up your site Settings Screen") ImmuTable.registerRows([SwitchRow.self], tableView: tableView) WPStyleGuide.configureColors(view: view, tableView: tableView) @@ -40,6 +42,7 @@ open class JetpackSpeedUpSiteSettingsViewController: UITableViewController { open override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + reloadViewModel() refreshSettings() } @@ -51,34 +54,18 @@ open class JetpackSpeedUpSiteSettingsViewController: UITableViewController { } func tableViewModel() -> ImmuTable { - - let serveImagesFromOurServers = SwitchRow(title: NSLocalizedString("Serve images from our servers", - comment: "Title for the Serve images from our servers setting"), - value: self.settings.jetpackServeImagesFromOurServers, - onChange: self.serveImagesFromOurServersValueChanged()) - - let lazyLoadImages = SwitchRow(title: NSLocalizedString("\"Lazy-load\" images", - comment: "Title for the lazy load images setting"), - value: self.settings.jetpackLazyLoadImages, - onChange: self.lazyLoadImagesValueChanged()) + let serveImagesFromOurServers = SwitchRow( + title: NSLocalizedString("Serve images from our servers", + comment: "Title for the Serve images from our servers setting"), + value: self.settings.jetpackServeImagesFromOurServers, + onChange: self.serveImagesFromOurServersValueChanged()) return ImmuTable(sections: [ ImmuTableSection( headerText: "", rows: [serveImagesFromOurServers], - footerText: NSLocalizedString("Jetpack will optimize your images and serve them from the server " + - "location nearest to your visitors. Using our global content delivery " + - "network will boost the loading speed of your site.", - comment: "Footer for the Serve images from our servers setting")), - - ImmuTableSection( - headerText: "", - rows: [lazyLoadImages], - footerText: NSLocalizedString("Improve your site's speed by only loading images visible on the screen. " + - "New images will load just before they scroll into view. This prevents " + - "viewers from having to download all the images on a page all at once, " + - "even ones they can't see.", - comment: "Footer for the Serve images from our servers setting")), + footerText: NSLocalizedString("Jetpack will optimize your images and serve them from the server location nearest to your visitors. Using our global content delivery network will boost the loading speed of your site.", comment: "Footer for the Serve images from our servers setting") + ) ]) } @@ -92,36 +79,25 @@ open class JetpackSpeedUpSiteSettingsViewController: UITableViewController { self.service.updateJetpackServeImagesFromOurServersModuleSettingForBlog(self.blog, success: {}, - failure: { [weak self] (_) in + failure: { [weak self] _ in self?.refreshSettingsAfterSavingError() }) } } - fileprivate func lazyLoadImagesValueChanged() -> (_ newValue: Bool) -> Void { - return { [unowned self] newValue in - self.settings.jetpackLazyLoadImages = newValue - self.reloadViewModel() - WPAnalytics.trackSettingsChange("jetpack_speed_up_site", fieldName: "lazy_load_images", value: newValue as Any) - self.service.updateJetpackLazyImagesModuleSettingForBlog(self.blog, - success: {}, - failure: { [weak self] (_) in - self?.refreshSettingsAfterSavingError() - }) - } - } - // MARK: - Persistance fileprivate func refreshSettings() { - service.syncJetpackModulesForBlog(blog, - success: { [weak self] in - self?.reloadViewModel() - DDLogInfo("Reloaded Speed up site settings") - }, - failure: { (error: Error?) in - DDLogError("Error while syncing blog Speed up site settings: \(String(describing: error))") - }) + service.syncJetpackModulesForBlog( + blog, + success: { [weak self] in + self?.reloadViewModel() + DDLogInfo("Reloaded Speed up site settings") + }, + failure: { (error: Error?) in + Notice(title: SharedStrings.Error.refreshFailed, message: error?.localizedDescription).post() + DDLogError("Error while syncing blog Speed up site settings: \(String(describing: error))") + }) } fileprivate func refreshSettingsAfterSavingError() { diff --git a/WordPress/Classes/ViewRelated/Jetpack/Social/JetpackSocialNoConnectionView.swift b/WordPress/Classes/ViewRelated/Jetpack/Social/JetpackSocialNoConnectionView.swift index f1d7556e0c5a..81db3538dde0 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Social/JetpackSocialNoConnectionView.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Social/JetpackSocialNoConnectionView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AsyncImageKit struct JetpackSocialNoConnectionView: View { @@ -40,7 +41,7 @@ struct JetpackSocialNoConnectionView: View { } func iconImage(image: UIImage, url: URL?) -> some View { - AsyncImage(url: url) { image in + CachedAsyncImage(url: url) { image in image .icon(backgroundColor: viewModel.preferredBackgroundColor) } placeholder: { diff --git a/WordPress/Classes/ViewRelated/Me/All Domains/Views/DomainPurchaseChoicesView.swift b/WordPress/Classes/ViewRelated/Me/All Domains/Views/DomainPurchaseChoicesView.swift index 066a82beaa50..9f446d6fe50c 100644 --- a/WordPress/Classes/ViewRelated/Me/All Domains/Views/DomainPurchaseChoicesView.swift +++ b/WordPress/Classes/ViewRelated/Me/All Domains/Views/DomainPurchaseChoicesView.swift @@ -85,7 +85,7 @@ struct DomainPurchaseChoicesView: View { Image(imageName) .renderingMode(.template) .resizable() - .foregroundStyle(AppColor.brand) + .foregroundStyle(AppColor.primary) .frame(width: Constants.imageLength, height: Constants.imageLength) .padding(.top, .DS.Padding.double) VStack(alignment: .leading, spacing: .DS.Padding.single) { @@ -95,7 +95,7 @@ struct DomainPurchaseChoicesView: View { .foregroundStyle(.secondary) if let footer { Text(footer) - .foregroundStyle(AppColor.brand) + .foregroundStyle(AppColor.primary) .font(.body.bold()) } } @@ -122,7 +122,7 @@ struct DomainPurchaseChoicesView: View { Text(Strings.chooseSiteSubtitle) .foregroundStyle(Color(.secondaryLabel)) Text(Strings.chooseSiteFooter) - .foregroundStyle(AppColor.brand) + .foregroundStyle(AppColor.primary) } } diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/Boolean User Defaults/BooleanUserDefaultsDebugView.swift b/WordPress/Classes/ViewRelated/Me/App Settings/Boolean User Defaults/BooleanUserDefaultsDebugView.swift index 1195f1f73deb..966ae873f066 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/Boolean User Defaults/BooleanUserDefaultsDebugView.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/Boolean User Defaults/BooleanUserDefaultsDebugView.swift @@ -32,7 +32,7 @@ struct BooleanUserDefaultsDebugView: View { .onAppear { viewModel.load() } - .tint(AppColor.brand) + .tint(AppColor.primary) } } diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/DebugMenuViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/DebugMenuViewController.swift index 0478d76a9bc5..99cbeb3076c7 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/DebugMenuViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/DebugMenuViewController.swift @@ -86,7 +86,7 @@ struct DebugMenuView: View { HStack { Text(Strings.encryptedLogging) Spacer() - Image(systemName: "chevron.right") + Image(systemName: "chevron.forward") .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary.opacity(0.5)) } diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/Privacy Settings/PrivacySettingsViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/Privacy Settings/PrivacySettingsViewController.swift index 7f295f430f68..50be801ad3d7 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/Privacy Settings/PrivacySettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/Privacy Settings/PrivacySettingsViewController.swift @@ -38,7 +38,7 @@ class PrivacySettingsViewController: UITableViewController { PaddedInfoRow.self, SwitchRow.self, PaddedLinkRow.self - ], tableView: self.tableView) + ], tableView: self.tableView) handler = ImmuTableViewHandler(takeOver: self) reloadViewModel() diff --git a/WordPress/Classes/ViewRelated/Me/My Profile/Gravatar/GravatarQuickEditorPresenter.swift b/WordPress/Classes/ViewRelated/Me/My Profile/Gravatar/GravatarQuickEditorPresenter.swift index cb0ec4456d2e..dc77c501ea6e 100644 --- a/WordPress/Classes/ViewRelated/Me/My Profile/Gravatar/GravatarQuickEditorPresenter.swift +++ b/WordPress/Classes/ViewRelated/Me/My Profile/Gravatar/GravatarQuickEditorPresenter.swift @@ -2,7 +2,7 @@ import Foundation import GravatarUI import WordPressShared import WordPressAuthenticator -import WordPressMedia +import AsyncImageKit @MainActor struct GravatarQuickEditorPresenter { diff --git a/WordPress/Classes/ViewRelated/Media/AnimatedImageCache.swift b/WordPress/Classes/ViewRelated/Media/AnimatedImageCache.swift deleted file mode 100644 index 47d0f2b782f2..000000000000 --- a/WordPress/Classes/ViewRelated/Media/AnimatedImageCache.swift +++ /dev/null @@ -1,99 +0,0 @@ -import UIKit -import WordPressMedia - -/// AnimatedImageCache is an image + animated gif data cache used in -/// CachedAnimatedImageView. It should be accessed via the `shared` singleton. -/// -final class AnimatedImageCache { - - // MARK: Singleton - - static let shared: AnimatedImageCache = AnimatedImageCache() - - private init() {} - - // MARK: Private fields - - fileprivate lazy var session: URLSession = { - let sessionConfiguration = URLSessionConfiguration.default - let session = URLSession(configuration: sessionConfiguration) - return session - }() - - // MARK: Instance methods - - func cacheData(data: Data, url: URL?) { - guard let url else { return } - let key = url.absoluteString + Constants.keyDataSuffix - MemoryCache.shared.setData(data, forKey: key) - } - - func cachedData(url: URL?) -> Data? { - guard let url else { return nil } - let key = url.absoluteString + Constants.keyDataSuffix - return MemoryCache.shared.geData(forKey: key) - } - - func cacheStaticImage(url: URL?, image: UIImage?) { - guard let url, let image else { return } - let key = url.absoluteString + Constants.keyStaticImageSuffix - MemoryCache.shared.setImage(image, forKey: key) - } - - func cachedStaticImage(url: URL?) -> UIImage? { - guard let url else { return nil } - let key = url.absoluteString + Constants.keyStaticImageSuffix - return MemoryCache.shared.getImage(forKey: key) - } - - func animatedImage(_ urlRequest: URLRequest, - placeholderImage: UIImage?, - success: ((Data, UIImage?) -> Void)?, - failure: ((NSError?) -> Void)? ) -> URLSessionTask? { - - if let cachedImageData = cachedData(url: urlRequest.url) { - success?(cachedImageData, cachedStaticImage(url: urlRequest.url)) - return nil - } - - let task = session.dataTask(with: urlRequest, completionHandler: { [weak self] (data, response, error) in - //check if view is still here - guard let self else { - return - } - // check if there is an error - if let error { - let nsError = error as NSError - // task.cancel() triggers an error that we don't want to send to the error handler. - if nsError.code != NSURLErrorCancelled { - failure?(nsError) - } - return - } - // check if data is here and is animated gif - guard let data else { - failure?(nil) - return - } - - let staticImage = UIImage(data: data) - if let key = urlRequest.url { - self.cacheData(data: data, url: key) - self.cacheStaticImage(url: key, image: staticImage) - } - success?(data, staticImage) - }) - - task.resume() - return task - } -} - -// MARK: - Constants - -private extension AnimatedImageCache { - struct Constants { - static let keyDataSuffix = "_data" - static let keyStaticImageSuffix = "_static_image" - } -} diff --git a/WordPress/Classes/ViewRelated/Media/CachedAnimatedImageView.swift b/WordPress/Classes/ViewRelated/Media/CachedAnimatedImageView.swift deleted file mode 100644 index 95e52342a294..000000000000 --- a/WordPress/Classes/ViewRelated/Media/CachedAnimatedImageView.swift +++ /dev/null @@ -1,285 +0,0 @@ -// -// Previously, we were using FLAnimatedImage to show gifs. (https://github.com/Flipboard/FLAnimatedImage) -// It's a good, battle-tested component written in Obj-c with a good solution for memory usage on big files. -// We decided to look for other alternatives and we got to Gifu. (https://github.com/kaishin/Gifu) -// - It has a similar approach to be memory efficient. Tests showed that is more memory efficient than FLAnimatedImage. -// - It's written in Swift, in a protocol oriented approach. That make it easier to implement it in a Swift code base. -// - It has extra features, like stopping and plying gifs, and a special `prepareForReuse` for table/collection views. -// - It seems to be more active, being updated few months ago, in contrast to a couple of years ago of FLAnimatedImage - -import Foundation -import Gifu - -@objc public protocol ActivityIndicatorType where Self: UIView { - func startAnimating() - func stopAnimating() -} - -extension UIActivityIndicatorView: ActivityIndicatorType { -} - -public class CachedAnimatedImageView: UIImageView, GIFAnimatable { - - public enum LoadingIndicatorStyle { - case centered(withSize: CGSize?) - case fullView - } - - // MARK: Public fields - - @objc public var gifStrategy: GIFStrategy { - get { - return gifPlaybackStrategy.gifStrategy - } - set(newGifStrategy) { - gifPlaybackStrategy = newGifStrategy.playbackStrategy - } - } - - @objc public private(set) var animatedGifData: Data? - - public lazy var animator: Gifu.Animator? = { - return Gifu.Animator(withDelegate: self) - }() - - @objc public var shouldShowLoadingIndicator: Bool = true - - // MARK: Private fields - - private var gifPlaybackStrategy: GIFPlaybackStrategy = MediumGIFPlaybackStrategy() - - @objc private var currentTask: URLSessionTask? - - private var customLoadingIndicator: ActivityIndicatorType? - - private var isImageAnimated: Bool { - animatedGifData != nil - } - - private lazy var defaultLoadingIndicator: UIActivityIndicatorView = { - let loadingIndicator = UIActivityIndicatorView(style: .medium) - layoutViewCentered(loadingIndicator, size: nil) - return loadingIndicator - }() - - private var loadingIndicator: ActivityIndicatorType { - guard let custom = customLoadingIndicator else { - return defaultLoadingIndicator - } - return custom - } - - // MARK: Initializers - - public override init(image: UIImage?, highlightedImage: UIImage?) { - super.init(image: image, highlightedImage: highlightedImage) - commonInit() - } - - public override init(frame: CGRect) { - super.init(frame: frame) - commonInit() - } - - public override init(image: UIImage?) { - super.init(image: image) - commonInit() - } - - public required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - commonInit() - } - - private func commonInit() { - NotificationCenter.default.addObserver(self, - selector: #selector(handleLowMemoryWarningNotification), - name: UIApplication.didReceiveMemoryWarningNotification, - object: nil) - } - - // MARK: - Public methods - - override open func display(_ layer: CALayer) { - // Fixes an unrecognized selector crash on iOS 13 and below when calling super.display(_:) directly - // This was first reported here: p5T066-1xs-p2#comment-5908 - // Investigating the issue I came across this discussion with a workaround in the Gifu repo: https://git.io/JUPxC - if UIImageView.instancesRespond(to: #selector(display(_:))) { - super.display(layer) - } - - updateImageIfNeeded() - } - - @objc public func setAnimatedImage(_ urlRequest: URLRequest, - placeholderImage: UIImage?, - success: (() -> Void)?, - failure: ((NSError?) -> Void)?) { - - currentTask?.cancel() - image = placeholderImage - - if checkCache(urlRequest, success) { - return - } - - let successBlock: (Data, UIImage?) -> Void = { [weak self] animatedImageData, staticImage in - self?.validateAndSetGifData(animatedImageData, alternateStaticImage: staticImage, success: success) - } - - currentTask = AnimatedImageCache.shared.animatedImage(urlRequest, - placeholderImage: placeholderImage, - success: successBlock, - failure: failure) - } - - @objc public func setAnimatedImage(_ animatedImageData: Data, success: (() -> Void)? = nil) { - currentTask?.cancel() - validateAndSetGifData(animatedImageData, alternateStaticImage: nil, success: success) - } - - /// Clean the image view from previous images and ongoing data tasks. - /// - @objc public func clean() { - currentTask?.cancel() - image = nil - animatedGifData = nil - } - - @objc public func prepForReuse() { - if isImageAnimated { - self.prepareForReuse() - } - } - - @objc public func startLoadingAnimation() { - guard shouldShowLoadingIndicator else { - return - } - DispatchQueue.main.async() { - self.loadingIndicator.startAnimating() - } - } - - @objc public func stopLoadingAnimation() { - DispatchQueue.main.async() { - self.loadingIndicator.stopAnimating() - } - } - - public func addLoadingIndicator(_ loadingIndicator: ActivityIndicatorType, style: LoadingIndicatorStyle) { - removeCustomLoadingIndicator() - customLoadingIndicator = loadingIndicator - addCustomLoadingIndicator(loadingIndicator, style: style) - } - - // MARK: - Private methods - - @objc private func handleLowMemoryWarningNotification(_ notification: NSNotification) { - stopAnimatingGIF() - } - - private func validateAndSetGifData(_ animatedImageData: Data, alternateStaticImage: UIImage? = nil, success: (() -> Void)? = nil) { - let didVerifyDataSize = gifPlaybackStrategy.verifyDataSize(animatedImageData) - DispatchQueue.main.async() { - if let staticImage = alternateStaticImage { - self.image = staticImage - } else { - self.image = UIImage(data: animatedImageData) - } - - DispatchQueue.global().async { - if didVerifyDataSize { - self.animate(data: animatedImageData, success: success) - } else { - self.animatedGifData = nil - success?() - } - } - } - } - - private func checkCache(_ urlRequest: URLRequest, _ success: (() -> Void)?) -> Bool { - if let cachedData = AnimatedImageCache.shared.cachedData(url: urlRequest.url) { - // Always attempt to load momentary image to show while gif is loading to avoid flashing. - if let cachedStaticImage = AnimatedImageCache.shared.cachedStaticImage(url: urlRequest.url) { - image = cachedStaticImage - } else { - animatedGifData = nil - let staticImage = UIImage(data: cachedData) - image = staticImage - AnimatedImageCache.shared.cacheStaticImage(url: urlRequest.url, image: staticImage) - } - - if gifPlaybackStrategy.verifyDataSize(cachedData) { - animate(data: cachedData, success: success) - } else { - success?() - } - - return true - } - - return false - } - - private func animate(data: Data, success: (() -> Void)?) { - animatedGifData = data - DispatchQueue.main.async() { - self.setFrameBufferCount(self.gifPlaybackStrategy.frameBufferCount) - self.animate(withGIFData: data, preparationBlock: { - success?() - }) - } - } - - // MARK: Loading indicator - - private func removeCustomLoadingIndicator() { - if let oldLoadingIndicator = customLoadingIndicator { - oldLoadingIndicator.removeFromSuperview() - } - } - - private func addCustomLoadingIndicator(_ loadingView: UIView, style: LoadingIndicatorStyle) { - switch style { - case .centered(let size): - layoutViewCentered(loadingView, size: size) - default: - layoutViewFullView(loadingView) - } - } - - // MARK: Layout - - private func prepareViewForLayout(_ view: UIView) { - if view.superview == nil { - addSubview(view) - } - view.translatesAutoresizingMaskIntoConstraints = false - } - - private func layoutViewCentered(_ view: UIView, size: CGSize?) { - prepareViewForLayout(view) - var constraints: [NSLayoutConstraint] = [ - view.centerXAnchor.constraint(equalTo: centerXAnchor), - view.centerYAnchor.constraint(equalTo: centerYAnchor) - ] - if let size { - constraints.append(view.heightAnchor.constraint(equalToConstant: size.height)) - constraints.append(view.widthAnchor.constraint(equalToConstant: size.width)) - } - NSLayoutConstraint.activate(constraints) - } - - private func layoutViewFullView(_ view: UIView) { - prepareViewForLayout(view) - NSLayoutConstraint.activate([ - view.leadingAnchor.constraint(equalTo: leadingAnchor), - view.trailingAnchor.constraint(equalTo: trailingAnchor), - view.topAnchor.constraint(equalTo: topAnchor), - view.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - -} diff --git a/WordPress/Classes/ViewRelated/Media/External/ExternalMediaPickerCollectionCell.swift b/WordPress/Classes/ViewRelated/Media/External/ExternalMediaPickerCollectionCell.swift index 2a50aeb6b2f3..7ee79240e2f8 100644 --- a/WordPress/Classes/ViewRelated/Media/External/ExternalMediaPickerCollectionCell.swift +++ b/WordPress/Classes/ViewRelated/Media/External/ExternalMediaPickerCollectionCell.swift @@ -1,4 +1,5 @@ import UIKit +import AsyncImageKit final class ExternalMediaPickerCollectionCell: UICollectionViewCell { private let imageView = AsyncImageView() @@ -22,7 +23,7 @@ final class ExternalMediaPickerCollectionCell: UICollectionViewCell { imageView.prepareForReuse() } - func configure(imageURL: URL, size: CGSize) { + func configure(imageURL: URL, size: ImageSize) { imageView.setImage(with: imageURL, size: size) } diff --git a/WordPress/Classes/ViewRelated/Media/External/ExternalMediaPickerViewController.swift b/WordPress/Classes/ViewRelated/Media/External/ExternalMediaPickerViewController.swift index f57cefc59aa4..b0ad72b0d00a 100644 --- a/WordPress/Classes/ViewRelated/Media/External/ExternalMediaPickerViewController.swift +++ b/WordPress/Classes/ViewRelated/Media/External/ExternalMediaPickerViewController.swift @@ -1,4 +1,5 @@ import UIKit +import AsyncImageKit protocol ExternalMediaPickerViewDelegate: AnyObject { /// If the user cancels the flow, the selection is empty. @@ -222,6 +223,7 @@ final class ExternalMediaPickerViewController: UIViewController, UICollectionVie let viewController = MediaPreviewController() viewController.dataSource = self let navigation = UINavigationController(rootViewController: viewController) + navigation.modalPresentationStyle = .fullScreen present(navigation, animated: true) } @@ -234,7 +236,7 @@ final class ExternalMediaPickerViewController: UIViewController, UICollectionVie func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Self.cellReuseID, for: indexPath) as! ExternalMediaPickerCollectionCell let item = dataSource.assets[indexPath.item] - cell.configure(imageURL: item.thumbnailURL, size: flowLayout.itemSize.scaled(by: UIScreen.main.scale)) + cell.configure(imageURL: item.thumbnailURL, size: ImageSize(scaling: flowLayout.itemSize)) return cell } diff --git a/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxImagePageViewController.swift b/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxImagePageViewController.swift new file mode 100644 index 000000000000..a71ace71fbde --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxImagePageViewController.swift @@ -0,0 +1,92 @@ +import UIKit +import WordPressUI +import AsyncImageKit + +final class LightboxImagePageViewController: UIViewController { + private(set) var scrollView = LightboxImageScrollView() + private let controller = ImageLoadingController() + private let siteMediaImageLoadingController = SiteMediaImageLoadingController() + private let item: LightboxItem + private let activityIndicator = UIActivityIndicatorView() + private var errorView: UIImageView? + + init(item: LightboxItem) { + self.item = item + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.addSubview(scrollView) + + activityIndicator.hidesWhenStopped = true + view.addSubview(activityIndicator) + activityIndicator.pinCenter() + + scrollView.onDismissTapped = { [weak self] in + self?.parent?.presentingViewController?.dismiss(animated: true) + } + + controller.onStateChanged = { [weak self] in + self?.setState($0) + } + + siteMediaImageLoadingController.onStateChanged = { [weak self] in + self?.setState($0) + } + + startFetching() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if scrollView.frame != view.bounds { + scrollView.frame = view.bounds + scrollView.configureLayout() + } + } + + private func startFetching() { + switch item { + case .image(let image): + setState(.success(image)) + case .asset(let asset): + controller.setImage(with: ImageRequest(url: asset.sourceURL, host: asset.host)) + case .media(let media): + siteMediaImageLoadingController.setImage(with: media, size: .original) + } + } + + private func setState(_ state: ImageLoadingController.State) { + switch state { + case .loading: + if scrollView.imageView.image == nil { + activityIndicator.startAnimating() + } + case .success(let image): + activityIndicator.stopAnimating() + scrollView.configure(with: image) + case .failure: + activityIndicator.stopAnimating() + makeErrorView().isHidden = false + } + } + + private func makeErrorView() -> UIImageView { + if let errorView { + return errorView + } + let errorView = UIImageView(image: UIImage(systemName: "exclamationmark.triangle")) + errorView.tintColor = .separator + view.addSubview(errorView) + errorView.pinCenter() + self.errorView = errorView + return errorView + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxImageScrollView.swift b/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxImageScrollView.swift new file mode 100644 index 000000000000..5cd72ac7c9a5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxImageScrollView.swift @@ -0,0 +1,132 @@ +import UIKit +import Gifu +import WordPressUI + +final class LightboxImageScrollView: UIScrollView, UIScrollViewDelegate { + let imageView = GIFImageView() + + var onDismissTapped: (() -> Void)? + + // MARK: - Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + + setupView() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Configuration + + func configure(with image: UIImage) { + imageView.configure(image: image) + configureImageView() + } + + private func setupView() { + addSubview(imageView) + + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.isUserInteractionEnabled = true + + delegate = self + isMultipleTouchEnabled = true + minimumZoomScale = 1 + maximumZoomScale = 3 + showsHorizontalScrollIndicator = false + showsVerticalScrollIndicator = false + + let doubleTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(didRecognizeDoubleTap)) + doubleTapRecognizer.numberOfTapsRequired = 2 + doubleTapRecognizer.numberOfTouchesRequired = 1 + addGestureRecognizer(doubleTapRecognizer) + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(didRecognizeTap)) + addGestureRecognizer(tapRecognizer) + + tapRecognizer.require(toFail: doubleTapRecognizer) + } + + // MARK: Recognizers + + @objc private func didRecognizeDoubleTap(_ recognizer: UITapGestureRecognizer) { + let zoomScale = zoomScale > minimumZoomScale ? minimumZoomScale : maximumZoomScale + let width = bounds.size.width / zoomScale + let height = bounds.size.height / zoomScale + + let location = recognizer.location(in: imageView) + let x = location.x - (width / 2.0) + let y = location.y - (height / 2.0) + + let rect = CGRect(x: x, y: y, width: width, height: height) + zoom(to: rect, animated: true) + } + + @objc private func didRecognizeTap(_ recognizer: UITapGestureRecognizer) { + onDismissTapped?() + } + + // MARK: Layout + + func configureLayout() { + contentSize = bounds.size + imageView.frame = bounds + zoomScale = minimumZoomScale + + configureImageView() + } + + private func configureImageView() { + guard let image = imageView.image else { + return centerImageView() + } + + let imageViewSize = imageView.frame.size + let imageSize = image.size + let actualImageSize: CGSize + + if imageSize.width / imageSize.height > imageViewSize.width / imageViewSize.height { + actualImageSize = CGSize( + width: imageViewSize.width, + height: imageViewSize.width / imageSize.width * imageSize.height) + } else { + actualImageSize = CGSize( + width: imageViewSize.height / imageSize.height * imageSize.width, + height: imageViewSize.height) + } + + imageView.frame = CGRect(origin: CGPoint.zero, size: actualImageSize) + + centerImageView() + } + + private func centerImageView() { + var newFrame = imageView.frame + if newFrame.size.width < bounds.size.width { + newFrame.origin.x = (bounds.size.width - newFrame.size.width) / 2.0 + } else { + newFrame.origin.x = 0.0 + } + + if newFrame.size.height < bounds.size.height { + newFrame.origin.y = (bounds.size.height - newFrame.size.height) / 2.0 + } else { + newFrame.origin.y = 0.0 + } + imageView.frame = newFrame + } + + // MARK: UIScrollViewDelegate + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + imageView + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + centerImageView() + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxItem.swift b/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxItem.swift new file mode 100644 index 000000000000..254f4aa49da2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxItem.swift @@ -0,0 +1,13 @@ +import Foundation +import AsyncImageKit + +enum LightboxItem { + case image(UIImage) + case asset(LightboxAsset) + case media(Media) +} + +struct LightboxAsset { + let sourceURL: URL + var host: MediaHost? +} diff --git a/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxViewController.swift b/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxViewController.swift new file mode 100644 index 000000000000..0f9f8debc036 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Lightbox/LightboxViewController.swift @@ -0,0 +1,161 @@ +import UIKit +import AsyncImageKit +import WordPressUI +import UniformTypeIdentifiers + +/// A fullscreen preview of a set of media assets. +final class LightboxViewController: UIViewController { + private var pageVC: LightboxImagePageViewController? + private var items: [LightboxItem] + + /// A thumbnail to display during transition and for the initial image download. + var thumbnail: UIImage? + + var configuration: Configuration + + struct Configuration { + var backgroundColor: UIColor = .black + var showsCloseButton = true + } + + convenience init(sourceURL: URL, host: MediaHost? = nil) { + let asset = LightboxAsset(sourceURL: sourceURL, host: host) + self.init(items: [.asset(asset)]) + } + + convenience init(media: Media) { + self.init(items: [.media(media)]) + } + + convenience init(_ item: LightboxItem, configuration: Configuration = .init()) { + self.init(items: [item]) + } + + private init(items: [LightboxItem], configuration: Configuration = .init()) { + assert(items.count == 1, "Current API supports only one item at a time") + self.items = items + self.configuration = configuration + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = configuration.backgroundColor + + if let item = items.first { + show(item) + } + if configuration.showsCloseButton { + addCloseButton() + } + } + + private func show(_ item: LightboxItem) { + let pageVC = LightboxImagePageViewController(item: item) + pageVC.willMove(toParent: self) + addChild(pageVC) + view.addSubview(pageVC.view) + pageVC.view.pinEdges() + pageVC.didMove(toParent: self) + if let thumbnail { + pageVC.scrollView.configure(with: thumbnail) + self.thumbnail = nil + } + self.pageVC = pageVC + } + + private func addCloseButton() { + let button = UIButton(type: .system) + let image = UIImage(systemName: "xmark.circle.fill")? + .withConfiguration(UIImage.SymbolConfiguration(font: .systemFont(ofSize: 22, weight: .medium))) + .applyingSymbolConfiguration(UIImage.SymbolConfiguration(paletteColors: [.lightGray, .opaqueSeparator.withAlphaComponent(0.2)])) + button.setImage(image, for: []) + button.addTarget(self, action: #selector(buttonCloseTapped), for: .primaryActionTriggered) + button.accessibilityLabel = SharedStrings.Button.close + view.addSubview(button) + button.pinEdges([.top, .trailing], to: view.safeAreaLayoutGuide, insets: UIEdgeInsets(.all, 8)) + } + + @objc private func buttonCloseTapped() { + presentingViewController?.dismiss(animated: true) + } + + // MARK: Presentation + + func configureZoomTransition(souceItemProvider: @escaping (UIViewController) -> UIView?) { + if #available(iOS 18.0, *) { + let options = UIViewController.Transition.ZoomOptions() + options.alignmentRectProvider = { context in + // For more info, see https://douglashill.co/zoom-transitions/#Zooming-to-only-part-of-the-destination-view + let detailViewController = context.zoomedViewController as! LightboxViewController + let detailsView: UIView = detailViewController.pageVC?.scrollView.imageView ?? detailViewController.view + return detailsView.convert(detailsView.bounds, to: detailViewController.view) + } + preferredTransition = .zoom(options: options) { context in + souceItemProvider(context.zoomedViewController) + } + } else { + modalTransitionStyle = .crossDissolve + } + } + + func configureZoomTransition(sourceView: UIView? = nil) { + configureZoomTransition { _ in sourceView } + if let sourceView, thumbnail == nil { + MainActor.assumeIsolated { + thumbnail = getThumbnail(fromSourceView: sourceView) + } + } + } +} + +@MainActor +private func getThumbnail(fromSourceView sourceView: UIView) -> UIImage? { + if let imageView = sourceView as? AsyncImageView { + return imageView.image + } + if let imageView = sourceView as? UIImageView { + return imageView.image + } + return nil +} + +@available(iOS 17, *) +#Preview { + UINavigationController(rootViewController: LightboxDemoViewController()) +} + +/// An example of ``LightboxController`` usage. +final class LightboxDemoViewController: UIViewController { + private let imageView = UIImageView() + private let imageURL = URL(string: "https://github.com/user-attachments/assets/5a1d0d95-8ce6-4a87-8175-d67396511143")! + + override func viewDidLoad() { + super.viewDidLoad() + + view.addSubview(imageView) + imageView.pinCenter() + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 120), + imageView.heightAnchor.constraint(equalToConstant: 80), + ]) + + Task { @MainActor in + imageView.image = try? await ImageDownloader.shared.image(from: imageURL) + } + + imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imageTapped))) + imageView.isUserInteractionEnabled = true + } + + @objc private func imageTapped() { + let lightboxVC = LightboxViewController(sourceURL: imageURL) + lightboxVC.configureZoomTransition(sourceView: imageView) + present(lightboxVC, animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaItemViewController.swift b/WordPress/Classes/ViewRelated/Media/MediaItemViewController.swift index 2d1e29ddb088..59f28acbb564 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaItemViewController.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaItemViewController.swift @@ -150,7 +150,7 @@ final class MediaItemViewController: UITableViewController { style: .plain, target: self, action: #selector(shareTapped)) - shareItem.accessibilityLabel = NSLocalizedString("Share", comment: "Accessibility label for share buttons in nav bars") + shareItem.accessibilityLabel = SharedStrings.Button.share let trashItem = UIBarButtonItem(image: UIImage(systemName: "trash"), style: .plain, @@ -179,11 +179,10 @@ final class MediaItemViewController: UITableViewController { } private func presentImageViewControllerForMedia() { - let controller = WPImageViewController(media: self.media) - controller.modalTransitionStyle = .crossDissolve - controller.modalPresentationStyle = .fullScreen - - self.present(controller, animated: true) + let controller = LightboxViewController(media: media) + controller.thumbnail = headerView.imageView.image + controller.configureZoomTransition(sourceView: headerView.imageView) + present(controller, animated: true) } private func presentVideoViewControllerForMedia() { diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/Helpers/MediaPickerMenuController.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/Helpers/MediaPickerMenuController.swift new file mode 100644 index 000000000000..b6db786883e9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/Helpers/MediaPickerMenuController.swift @@ -0,0 +1,62 @@ +import Photos +import PhotosUI + +final class MediaPickerMenuController: NSObject { + var onSelection: ((MediaPickerSelection) -> Void)? + + fileprivate func didSelect(_ items: [MediaPickerItem], source: String) { + let selection = MediaPickerSelection(items: items, source: source) + DispatchQueue.main.async { + self.onSelection?(selection) + } + } +} + +extension MediaPickerMenuController: PHPickerViewControllerDelegate { + public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.presentingViewController?.dismiss(animated: true) + if !results.isEmpty { + self.didSelect(results.map(MediaPickerItem.pickerResult), source: "apple_photos") + } + } +} + +extension MediaPickerMenuController: ImagePickerControllerDelegate { + func imagePicker(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + picker.presentingViewController?.dismiss(animated: true) + if let image = info[.originalImage] as? UIImage { + self.didSelect([.image(image)], source: "camera") + } + } +} + +extension MediaPickerMenuController: SiteMediaPickerViewControllerDelegate { + func siteMediaPickerViewController(_ viewController: SiteMediaPickerViewController, didFinishWithSelection selection: [Media]) { + viewController.presentingViewController?.dismiss(animated: true) + if !selection.isEmpty { + self.didSelect(selection.map(MediaPickerItem.media), source: "site_media") + } + } +} + +extension MediaPickerMenuController: ImagePlaygroundPickerDelegate { + func imagePlaygroundViewController(_ viewController: UIViewController, didCreateImageAt imageURL: URL) { + + viewController.presentingViewController?.dismiss(animated: true) + if let data = try? Data(contentsOf: imageURL), let image = UIImage(data: data) { + self.didSelect([.image(image)], source: "image_playground") + } else { + wpAssertionFailure("failed to read the image created by ImagePlayground") + } + } +} + +extension MediaPickerMenuController: ExternalMediaPickerViewDelegate { + func externalMediaPickerViewController(_ viewController: ExternalMediaPickerViewController, didFinishWithSelection selection: [ExternalMediaAsset]) { + viewController.presentingViewController?.dismiss(animated: true) + if !selection.isEmpty { + let source = viewController.source == .tenor ? "free_gifs" : "free_photos" + self.didSelect(selection.map(MediaPickerItem.external), source: source) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPicker.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPicker.swift new file mode 100644 index 000000000000..c2645cc0e1d2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPicker.swift @@ -0,0 +1,124 @@ +import SwiftUI +import WordPressUI +import Photos +import PhotosUI + +/// A media picker menu. +/// +/// - note: Use `.environment(\.presentingViewController, <#vc#>)` to pass the +/// presenting view controller. If not provided, the current top view controller +/// is used. +struct MediaPicker: View { + var configuration = MediaPickerConfiguration() + var onSelection: ((MediaPickerSelection) -> Void)? + + @ViewBuilder var content: () -> Content + + @StateObject private var viewModel = MediaPickerViewModel() + + @Environment(\.presentingViewController) var presentingViewController + + var body: some View { + Menu { + menu + } label: { + content() + } + } + + @ViewBuilder + private var menu: some View { + ForEach(makeActions(), id: \.self) { action in + Button { + action.performWithSender(nil, target: nil) + } label: { + Label { + Text(action.title) + } icon: { + action.image.map(Image.init) + } + } + } + } + + private func makeActions() -> [UIAction] { + let menu = MediaPickerMenu( + viewController: presentingViewController ?? UIViewController(), + filter: configuration.filter, + isMultipleSelectionEnabled: configuration.isMultipleSelectionEnabled + ) + + let controller = MediaPickerMenuController() + controller.onSelection = onSelection + viewModel.controller = controller // Needs to be retained + + return configuration.sources.compactMap { source in + switch source { + case .photos: + return menu.makePhotosAction(delegate: controller) + case .camera: + return menu.makeCameraAction(delegate: controller) + case .siteMedia(let blog): + return menu.makeSiteMediaAction(blog: blog, delegate: controller) + case .playground: + return menu.makeImagePlaygroundAction(delegate: controller) + case .freePhotos(let blog): + return menu.makeStockPhotos(blog: blog, delegate: controller) + case .freeGIFs(let blog): + return menu.makeFreeGIFAction(blog: blog, delegate: controller) + + } + } + } +} + +struct MediaPickerConfiguration { + var sources: [MediaPickerSource] = [.photos, .camera] + var filter: MediaPickerMenu.MediaFilter? + var isMultipleSelectionEnabled = false +} + +private final class MediaPickerViewModel: ObservableObject { + var controller: MediaPickerMenuController? +} + +enum MediaPickerSource { + case photos // Apple Photos + case camera + case siteMedia(blog: Blog) + case playground // Image Playground + case freePhotos(blog: Blog) // Pexels + case freeGIFs(blog: Blog) // Tenor +} + +struct MediaPickerSelection { + var items: [MediaPickerItem] + var source: String +} + +enum MediaPickerItem { + case pickerResult(PHPickerResult) + case image(UIImage) + case media(Media) + case external(ExternalMediaAsset) + + /// Prepares the item for export and upload to your site media. If the item + /// is already uploaded, returns `Media`. + func exported() -> Exportable { + switch self { + case .pickerResult(let result): + return .asset(result.itemProvider) + case .image(let image): + return .asset(image) + case .media(let media): + return .media(media) + case .external(let asset): + return .asset(asset) + } + } + + enum Exportable { + case asset(ExportableAsset) + case media(Media) + } +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPickerMenu.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPickerMenu.swift deleted file mode 100644 index 472b38236fed..000000000000 --- a/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPickerMenu.swift +++ /dev/null @@ -1,304 +0,0 @@ -import UIKit -import PhotosUI -import UniformTypeIdentifiers -import AVFoundation - -/// A convenience API for creating actions for picking media from different -/// source supported by the app: Photos library, Camera, Media library. -struct MediaPickerMenu { - weak var presentingViewController: UIViewController? - var filter: MediaFilter? - var isMultipleSelectionEnabled: Bool - var initialSelection: [Media] - - enum MediaFilter { - case images - case videos - } - - /// Initializes the options. - /// - /// - parameters: - /// - viewController: The view controller to use for presentation. - /// - filter: By default, `nil` – allow all content types. - /// - isMultipleSelectionEnabled: By default, `false`. - /// - initialSelection: By default, `[]`. - init(viewController: UIViewController, - filter: MediaFilter? = nil, - isMultipleSelectionEnabled: Bool = false, - initialSelection: [Media] = []) { - self.presentingViewController = viewController - self.filter = filter - self.isMultipleSelectionEnabled = isMultipleSelectionEnabled - self.initialSelection = initialSelection - } -} - -// MARK: - MediaPickerMenu (Photos) - -extension MediaPickerMenu { - /// Returns an action for picking photos from the device's Photos library. - /// - /// - note: Use `PHPickerResult.loadImage(for:)` to retrieve an image from the result. - func makePhotosAction(delegate: PHPickerViewControllerDelegate) -> UIAction { - UIAction( - title: Strings.pickFromPhotosLibrary, - image: UIImage(systemName: "photo.on.rectangle.angled"), - attributes: [], - handler: { _ in showPhotosPicker(delegate: delegate) } - ) - } - - func showPhotosPicker(delegate: PHPickerViewControllerDelegate) { - var configuration = PHPickerConfiguration() - configuration.preferredAssetRepresentationMode = .current - if let filter { - switch filter { - case .images: - configuration.filter = .images - case .videos: - configuration.filter = .videos - } - } - if isMultipleSelectionEnabled { - configuration.selectionLimit = 0 - configuration.selection = .ordered - } - let picker = PHPickerViewController(configuration: configuration) - picker.delegate = delegate - presentingViewController?.present(picker, animated: true) - } -} - -// MARK: - MediaPickerMenu (Camera) - -protocol ImagePickerControllerDelegate: AnyObject { - // Hides `NSObject` and `UINavigationControllerDelegate` conformances that - // the original `UIImagePickerControllerDelegate` has. - - /// - parameter info: If the info is empty, nothing was selected. - func imagePicker(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) -} - -extension MediaPickerMenu { - /// Returns an action from capturing media using the device's camera. - /// - /// - parameters: - /// - camera: The camera to use. By default, `.rear`. - /// - delegate: The delegate. - func makeCameraAction( - camera: UIImagePickerController.CameraDevice = .rear, - delegate: ImagePickerControllerDelegate - ) -> UIAction { - UIAction( - title: cameraActionTitle, - image: UIImage(systemName: "camera"), - attributes: [], - handler: { _ in showCamera(camera: camera, delegate: delegate) } - ) - } - - private var cameraActionTitle: String { - guard let filter else { - return Strings.takePhotoOrVideo - } - switch filter { - case .images: return Strings.takePhoto - case .videos: return Strings.takeVideo - } - } - - func showCamera(camera: UIImagePickerController.CameraDevice = .rear, delegate: ImagePickerControllerDelegate) { - switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized, .notDetermined: - actuallyShowCamera(camera: camera, delegate: delegate) - case .restricted, .denied: - showAccessRestrictedAlert() - @unknown default: - showAccessRestrictedAlert() - } - } - - private func actuallyShowCamera(camera: UIImagePickerController.CameraDevice, delegate: ImagePickerControllerDelegate) { - let picker = UIImagePickerController() - picker.sourceType = .camera - picker.cameraDevice = camera - picker.videoQuality = .typeHigh - if let filter { - switch filter { - case .images: picker.mediaTypes = [UTType.image.identifier] - case .videos: picker.mediaTypes = [UTType.movie.identifier] - } - } else { - picker.mediaTypes = [UTType.image.identifier, UTType.movie.identifier] - } - - let delegate = ImagePickerDelegate(delegate: delegate) - picker.delegate = delegate - objc_setAssociatedObject(picker, &MediaPickerMenu.strongDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - - presentingViewController?.present(picker, animated: true) - } - - private func showAccessRestrictedAlert() { - let alert = UIAlertController(title: Strings.noCameraAccessTitle, message: Strings.noCameraAccessMessage, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: Strings.buttonOK, style: .cancel)) - alert.addAction(UIAlertAction(title: Strings.noCameraOpenSettings, style: .default) { _ in - guard let url = URL(string: UIApplication.openSettingsURLString) else { - return assertionFailure("Failed to create Open Settigns URL") - } - UIApplication.shared.open(url) - }) - presentingViewController?.present(alert, animated: true) - } - - private final class ImagePickerDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { - weak var delegate: ImagePickerControllerDelegate? - - init(delegate: ImagePickerControllerDelegate) { - self.delegate = delegate - } - - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { - delegate?.imagePicker(picker, didFinishPickingMediaWithInfo: info) - } - - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - delegate?.imagePicker(picker, didFinishPickingMediaWithInfo: [:]) - } - } - - private static var strongDelegateKey: UInt8 = 0 -} - -// MARK: - MediaPickerMenu (Site Media) - -extension MediaPickerMenu { - /// Returns an action for selecting media from the media uploaded by the user - /// to their site. - func makeSiteMediaAction(blog: Blog, delegate: SiteMediaPickerViewControllerDelegate) -> UIAction { - UIAction( - title: Strings.pickFromMedia, - image: UIImage(systemName: "photo.stack"), - attributes: [], - handler: { _ in showSiteMediaPicker(blog: blog, delegate: delegate) } - ) - } - - func showSiteMediaPicker(blog: Blog, delegate: SiteMediaPickerViewControllerDelegate) { - let viewController = SiteMediaPickerViewController( - blog: blog, - filter: filter.map { [$0.mediaType] }, - allowsMultipleSelection: isMultipleSelectionEnabled, - initialSelection: initialSelection - ) - viewController.delegate = delegate - let navigation = UINavigationController(rootViewController: viewController) - presentingViewController?.present(navigation, animated: true) - } -} - -// MARK: - MediaPickerMenu (Stock Photo) - -extension MediaPickerMenu { - func makeStockPhotos(blog: Blog, delegate: ExternalMediaPickerViewDelegate) -> UIAction? { - guard blog.supports(.stockPhotos) else { - return nil - } - return UIAction( - title: Strings.pickFromStockPhotos, - image: UIImage(systemName: "photo.on.rectangle"), - attributes: [], - handler: { _ in showStockPhotosPicker(blog: blog, delegate: delegate) } - ) - } - - func showStockPhotosPicker(blog: Blog, delegate: ExternalMediaPickerViewDelegate) { - guard let presentingViewController, - let api = blog.wordPressComRestApi() else { - return - } - - let picker = ExternalMediaPickerViewController( - dataSource: StockPhotosDataSource(service: DefaultStockPhotosService(api: api)), - source: .stockPhotos, - allowsMultipleSelection: isMultipleSelectionEnabled - ) - picker.title = Strings.pickFromStockPhotos - picker.welcomeView = StockPhotosWelcomeView() - picker.delegate = delegate - - let navigation = UINavigationController(rootViewController: picker) - presentingViewController.present(navigation, animated: true) - } -} - -// MARK: - MediaPickerMenu (Free GIF, Tenor) - -extension MediaPickerMenu { - func makeFreeGIFAction(blog: Blog, delegate: ExternalMediaPickerViewDelegate) -> UIAction? { - guard blog.supports(.tenor) else { - return nil - } - return UIAction( - title: Strings.pickFromTenor, - image: UIImage(systemName: "play.square.stack"), - attributes: [], - handler: { _ in showFreeGIFPicker(blog: blog, delegate: delegate) } - ) - } - - func showFreeGIFPicker(blog: Blog, delegate: ExternalMediaPickerViewDelegate) { - guard let presentingViewController else { return } - - let picker = ExternalMediaPickerViewController( - dataSource: TenorDataSource(service: TenorService()), - source: .tenor, - allowsMultipleSelection: isMultipleSelectionEnabled - ) - picker.title = Strings.pickFromTenor - picker.welcomeView = TenorWelcomeView() - picker.delegate = delegate - - let navigation = UINavigationController(rootViewController: picker) - presentingViewController.present(navigation, animated: true) - } -} - -// MARK: - Helpers - -extension MediaPickerMenu.MediaFilter { - init?(_ mediaType: WPMediaType) { - switch mediaType { - case .image: self = .images - case .video: self = .videos - default: return nil - } - } - - var mediaType: MediaType { - switch self { - case .images: return .image - case .videos: return .video - } - } -} - -private enum Strings { - // MARK: Actions - - static let pickFromPhotosLibrary = NSLocalizedString("mediaPicker.pickFromPhotosLibrary", value: "Choose from Device", comment: "The name of the action in the context menu") - static let takePhoto = NSLocalizedString("mediaPicker.takePhoto", value: "Take Photo", comment: "The name of the action in the context menu") - static let takeVideo = NSLocalizedString("mediaPicker.takeVideo", value: "Take Video", comment: "The name of the action in the context menu") - static let takePhotoOrVideo = NSLocalizedString("mediaPicker.takePhotoOrVideo", value: "Take Photo or Video", comment: "The name of the action in the context menu") - static let pickFromMedia = NSLocalizedString("mediaPicker.pickFromMediaLibrary", value: "Choose from Media", comment: "The name of the action in the context menu (user's WordPress Media Library") - static let pickFromStockPhotos = NSLocalizedString("mediaPicker.pickFromStockPhotos", value: "Free Photo Library", comment: "The name of the action in the context menu for selecting photos from free stock photos") - static let pickFromTenor = NSLocalizedString("mediaPicker.pickFromFreeGIFLibrary", value: "Free GIF Library", comment: "The name of the action in the context menu for selecting photos from Tenor (free GIF library)") - - // MARK: Misc - - static let noCameraAccessTitle = NSLocalizedString("mediaPicker.noCameraAccessTitle", value: "Media Capture", comment: "Title for alert when access to camera is not granted") - static let noCameraAccessMessage = NSLocalizedString("mediaPicker.noCameraAccessMessage", value: "This app needs permission to access the Camera to capture new media, please change the privacy settings if you wish to allow this.", comment: "Message for alert when access to camera is not granted") - static let noCameraOpenSettings = NSLocalizedString("mediaPicker.openSettings", value: "Open Settings", comment: "Button that opens the Settings app") - static let buttonOK = NSLocalizedString("OK", value: "OK", comment: "OK") -} diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+Camera.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+Camera.swift new file mode 100644 index 000000000000..2a3648df7fcf --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+Camera.swift @@ -0,0 +1,109 @@ +import UIKit + +protocol ImagePickerControllerDelegate: AnyObject { + // Hides `NSObject` and `UINavigationControllerDelegate` conformances that + // the original `UIImagePickerControllerDelegate` has. + + /// - parameter info: If the info is empty, nothing was selected. + func imagePicker(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) +} + +extension MediaPickerMenu { + /// Returns an action from capturing media using the device's camera. + /// + /// - parameters: + /// - camera: The camera to use. By default, `.rear`. + /// - delegate: The delegate. + func makeCameraAction( + camera: UIImagePickerController.CameraDevice = .rear, + delegate: ImagePickerControllerDelegate + ) -> UIAction { + UIAction( + title: cameraActionTitle, + image: UIImage(systemName: "camera"), + attributes: [], + handler: { _ in showCamera(camera: camera, delegate: delegate) } + ) + } + + private var cameraActionTitle: String { + guard let filter else { + return Strings.takePhotoOrVideo + } + switch filter { + case .images: return Strings.takePhoto + case .videos: return Strings.takeVideo + } + } + + func showCamera(camera: UIImagePickerController.CameraDevice = .rear, delegate: ImagePickerControllerDelegate) { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized, .notDetermined: + actuallyShowCamera(camera: camera, delegate: delegate) + case .restricted, .denied: + showAccessRestrictedAlert() + @unknown default: + showAccessRestrictedAlert() + } + } + + private func actuallyShowCamera(camera: UIImagePickerController.CameraDevice, delegate: ImagePickerControllerDelegate) { + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.cameraDevice = camera + picker.videoQuality = .typeHigh + if let filter { + switch filter { + case .images: picker.mediaTypes = [UTType.image.identifier] + case .videos: picker.mediaTypes = [UTType.movie.identifier] + } + } else { + picker.mediaTypes = [UTType.image.identifier, UTType.movie.identifier] + } + + let delegate = ImagePickerDelegate(delegate: delegate) + picker.delegate = delegate + objc_setAssociatedObject(picker, &MediaPickerMenu.strongDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + presentingViewController?.present(picker, animated: true) + } + + private func showAccessRestrictedAlert() { + let alert = UIAlertController(title: Strings.noCameraAccessTitle, message: Strings.noCameraAccessMessage, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: SharedStrings.Button.ok, style: .cancel)) + alert.addAction(UIAlertAction(title: Strings.noCameraOpenSettings, style: .default) { _ in + guard let url = URL(string: UIApplication.openSettingsURLString) else { + return assertionFailure("Failed to create Open Settigns URL") + } + UIApplication.shared.open(url) + }) + presentingViewController?.present(alert, animated: true) + } + + private final class ImagePickerDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + weak var delegate: ImagePickerControllerDelegate? + + init(delegate: ImagePickerControllerDelegate) { + self.delegate = delegate + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + delegate?.imagePicker(picker, didFinishPickingMediaWithInfo: info) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + delegate?.imagePicker(picker, didFinishPickingMediaWithInfo: [:]) + } + } + + private static var strongDelegateKey: UInt8 = 0 +} + +private enum Strings { + static let takePhoto = NSLocalizedString("mediaPicker.takePhoto", value: "Take Photo", comment: "The name of the action in the context menu") + static let takeVideo = NSLocalizedString("mediaPicker.takeVideo", value: "Take Video", comment: "The name of the action in the context menu") + static let takePhotoOrVideo = NSLocalizedString("mediaPicker.takePhotoOrVideo", value: "Take Photo or Video", comment: "The name of the action in the context menu") + static let noCameraAccessTitle = NSLocalizedString("mediaPicker.noCameraAccessTitle", value: "Media Capture", comment: "Title for alert when access to camera is not granted") + static let noCameraAccessMessage = NSLocalizedString("mediaPicker.noCameraAccessMessage", value: "This app needs permission to access the Camera to capture new media, please change the privacy settings if you wish to allow this.", comment: "Message for alert when access to camera is not granted") + static let noCameraOpenSettings = NSLocalizedString("mediaPicker.openSettings", value: "Open Settings", comment: "Button that opens the Settings app") +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+External.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+External.swift new file mode 100644 index 000000000000..98b0d17c1223 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+External.swift @@ -0,0 +1,73 @@ +import UIKit + +// MARK: - MediaPickerMenu (Stock Photo) + +extension MediaPickerMenu { + func makeStockPhotos(blog: Blog, delegate: ExternalMediaPickerViewDelegate) -> UIAction? { + guard blog.supports(.stockPhotos) else { + return nil + } + return UIAction( + title: Strings.pickFromStockPhotos, + image: UIImage(systemName: "photo.on.rectangle"), + attributes: [], + handler: { _ in showStockPhotosPicker(blog: blog, delegate: delegate) } + ) + } + + func showStockPhotosPicker(blog: Blog, delegate: ExternalMediaPickerViewDelegate) { + guard let presentingViewController, + let api = blog.wordPressComRestApi() else { + return + } + + let picker = ExternalMediaPickerViewController( + dataSource: StockPhotosDataSource(service: DefaultStockPhotosService(api: api)), + source: .stockPhotos, + allowsMultipleSelection: isMultipleSelectionEnabled + ) + picker.title = Strings.pickFromStockPhotos + picker.welcomeView = StockPhotosWelcomeView() + picker.delegate = delegate + + let navigation = UINavigationController(rootViewController: picker) + presentingViewController.present(navigation, animated: true) + } +} + +// MARK: - MediaPickerMenu (Free GIF, Tenor) + +extension MediaPickerMenu { + func makeFreeGIFAction(blog: Blog, delegate: ExternalMediaPickerViewDelegate) -> UIAction? { + guard blog.supports(.tenor) else { + return nil + } + return UIAction( + title: Strings.pickFromTenor, + image: UIImage(systemName: "play.square.stack"), + attributes: [], + handler: { _ in showFreeGIFPicker(blog: blog, delegate: delegate) } + ) + } + + func showFreeGIFPicker(blog: Blog, delegate: ExternalMediaPickerViewDelegate) { + guard let presentingViewController else { return } + + let picker = ExternalMediaPickerViewController( + dataSource: TenorDataSource(service: TenorService()), + source: .tenor, + allowsMultipleSelection: isMultipleSelectionEnabled + ) + picker.title = Strings.pickFromTenor + picker.welcomeView = TenorWelcomeView() + picker.delegate = delegate + + let navigation = UINavigationController(rootViewController: picker) + presentingViewController.present(navigation, animated: true) + } +} + +private enum Strings { + static let pickFromStockPhotos = NSLocalizedString("mediaPicker.pickFromStockPhotos", value: "Free Photo Library", comment: "The name of the action in the context menu for selecting photos from free stock photos") + static let pickFromTenor = NSLocalizedString("mediaPicker.pickFromFreeGIFLibrary", value: "Free GIF Library", comment: "The name of the action in the context menu for selecting photos from Tenor (free GIF library)") +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPickerMenu+ImagePlayground.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+ImagePlayground.swift similarity index 96% rename from WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPickerMenu+ImagePlayground.swift rename to WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+ImagePlayground.swift index 7946391bd8d2..3669e143350d 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaPicker/MediaPickerMenu+ImagePlayground.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+ImagePlayground.swift @@ -13,8 +13,11 @@ extension MediaPickerMenu { Strings.imagePlayground } - func makeImagePlaygroundAction(delegate: ImagePlaygroundPickerDelegate) -> UIAction { - UIAction( + func makeImagePlaygroundAction(delegate: ImagePlaygroundPickerDelegate) -> UIAction? { + guard MediaPickerMenu.isImagePlaygroundAvailable else { + return nil + } + return UIAction( title: Strings.imagePlayground, image: UIImage(systemName: "apple.image.playground"), attributes: [], diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+Photos.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+Photos.swift new file mode 100644 index 000000000000..243851fcd756 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+Photos.swift @@ -0,0 +1,40 @@ +import UIKit +import PhotosUI + +extension MediaPickerMenu { + /// Returns an action for picking photos from the device's Photos library. + /// + /// - note: Use `PHPickerResult.loadImage(for:)` to retrieve an image from the result. + func makePhotosAction(delegate: PHPickerViewControllerDelegate) -> UIAction { + UIAction( + title: Strings.pickFromPhotosLibrary, + image: UIImage(systemName: "photo.on.rectangle.angled"), + attributes: [], + handler: { _ in showPhotosPicker(delegate: delegate) } + ) + } + + func showPhotosPicker(delegate: PHPickerViewControllerDelegate) { + var configuration = PHPickerConfiguration() + configuration.preferredAssetRepresentationMode = .current + if let filter { + switch filter { + case .images: + configuration.filter = .images + case .videos: + configuration.filter = .videos + } + } + if isMultipleSelectionEnabled { + configuration.selectionLimit = 0 + configuration.selection = .ordered + } + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = delegate + presentingViewController?.present(picker, animated: true) + } +} + +private enum Strings { + static let pickFromPhotosLibrary = NSLocalizedString("mediaPicker.pickFromPhotosLibrary", value: "Choose from Device", comment: "The name of the action in the context menu") +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+SiteMedia.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+SiteMedia.swift new file mode 100644 index 000000000000..2b56cfd7b5bf --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu+SiteMedia.swift @@ -0,0 +1,30 @@ +import UIKit + +extension MediaPickerMenu { + /// Returns an action for selecting media from the media uploaded by the user + /// to their site. + func makeSiteMediaAction(blog: Blog, delegate: SiteMediaPickerViewControllerDelegate) -> UIAction { + UIAction( + title: Strings.pickFromMedia, + image: UIImage(systemName: "photo.stack"), + attributes: [], + handler: { _ in showSiteMediaPicker(blog: blog, delegate: delegate) } + ) + } + + func showSiteMediaPicker(blog: Blog, delegate: SiteMediaPickerViewControllerDelegate) { + let viewController = SiteMediaPickerViewController( + blog: blog, + filter: filter.map { [$0.mediaType] }, + allowsMultipleSelection: isMultipleSelectionEnabled, + initialSelection: initialSelection + ) + viewController.delegate = delegate + let navigation = UINavigationController(rootViewController: viewController) + presentingViewController?.present(navigation, animated: true) + } +} + +private enum Strings { + static let pickFromMedia = NSLocalizedString("mediaPicker.pickFromMediaLibrary", value: "Choose from Media", comment: "The name of the action in the context menu (user's WordPress Media Library") +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu.swift b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu.swift new file mode 100644 index 000000000000..53730bb8d795 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/MediaPicker/Menu/MediaPickerMenu.swift @@ -0,0 +1,49 @@ +import UIKit + +/// A convenience API for creating actions for picking media from different +/// source supported by the app: Photos library, Camera, Media library. +struct MediaPickerMenu { + weak var presentingViewController: UIViewController? + var filter: MediaFilter? + var isMultipleSelectionEnabled: Bool + var initialSelection: [Media] + + enum MediaFilter { + case images + case videos + } + + /// Initializes the options. + /// + /// - parameters: + /// - viewController: The view controller to use for presentation. + /// - filter: By default, `nil` – allow all content types. + /// - isMultipleSelectionEnabled: By default, `false`. + /// - initialSelection: By default, `[]`. + init(viewController: UIViewController, + filter: MediaFilter? = nil, + isMultipleSelectionEnabled: Bool = false, + initialSelection: [Media] = []) { + self.presentingViewController = viewController + self.filter = filter + self.isMultipleSelectionEnabled = isMultipleSelectionEnabled + self.initialSelection = initialSelection + } +} + +extension MediaPickerMenu.MediaFilter { + init?(_ mediaType: WPMediaType) { + switch mediaType { + case .image: self = .images + case .video: self = .videos + default: return nil + } + } + + var mediaType: MediaType { + switch self { + case .images: return .image + case .videos: return .video + } + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Preview/MediaPreviewController.swift b/WordPress/Classes/ViewRelated/Media/Preview/MediaPreviewController.swift index 6407c281c991..294ea77c9e06 100644 --- a/WordPress/Classes/ViewRelated/Media/Preview/MediaPreviewController.swift +++ b/WordPress/Classes/ViewRelated/Media/Preview/MediaPreviewController.swift @@ -22,6 +22,8 @@ final class MediaPreviewController: UIViewController, UIPageViewControllerDataSo override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = .systemBackground + configureNavigationItems() configurePageViewController() updateNavigationForCurrentViewController() @@ -52,26 +54,27 @@ final class MediaPreviewController: UIViewController, UIPageViewControllerDataSo } } - private func makePageViewController(at index: Int) -> MediaPreviewItemViewController? { + private func makePageViewController(at index: Int) -> LightboxViewController? { guard index >= 0 && index < numberOfItems, let item = dataSource?.previewController(self, previewItemAt: index) else { return nil } - let viewController = MediaPreviewItemViewController(externalMediaURL: item.url) - viewController.shouldDismissWithGestures = false - viewController.index = index + let viewController = LightboxViewController(sourceURL: item.url) + viewController.configuration.showsCloseButton = false + viewController.configuration.backgroundColor = .systemBackground + viewController.view.tag = index return viewController } // MARK: - UIPageViewControllerDataSource func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { - let index = (viewController as! MediaPreviewItemViewController).index + let index = (viewController as! LightboxViewController).view.tag return makePageViewController(at: index - 1) } func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { - let index = (viewController as! MediaPreviewItemViewController).index + let index = (viewController as! LightboxViewController).view.tag return makePageViewController(at: index + 1) } @@ -82,17 +85,13 @@ final class MediaPreviewController: UIViewController, UIPageViewControllerDataSo } private func updateNavigationForCurrentViewController() { - guard let viewController = pageViewController.viewControllers?.first as? MediaPreviewItemViewController else { + guard let viewController = pageViewController.viewControllers?.first as? LightboxViewController else { return } - navigationItem.title = String(format: Strings.title, String(viewController.index + 1), String(numberOfItems)) + navigationItem.title = String(format: Strings.title, String(viewController.view.tag + 1), String(numberOfItems)) } } -private final class MediaPreviewItemViewController: WPImageViewController { - var index = 0 -} - private enum Strings { static let title = NSLocalizedString("mediaPreview.NofM", value: "%@ of %@", comment: "Navigation title for media preview. Example: 1 of 3") } diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Controllers/SiteMediaAddMediaMenuController.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Controllers/SiteMediaAddMediaMenuController.swift index ebe6a25659a7..66c0434f51cd 100644 --- a/WordPress/Classes/ViewRelated/Media/SiteMedia/Controllers/SiteMediaAddMediaMenuController.swift +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Controllers/SiteMediaAddMediaMenuController.swift @@ -21,7 +21,7 @@ final class SiteMediaAddMediaMenuController: NSObject, PHPickerViewControllerDel menu.makeCameraAction(delegate: self), menu.makeImagePlaygroundAction(delegate: self), makeDocumentPickerAction(from: viewController) - ]) + ].compactMap { $0 }) ] let freeMediaActions: [UIAction] = [ menu.makeStockPhotos(blog: blog, delegate: self), diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Helpers/SiteMediaImageLoadingController.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Helpers/SiteMediaImageLoadingController.swift new file mode 100644 index 000000000000..cc6cf8cac6b3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Helpers/SiteMediaImageLoadingController.swift @@ -0,0 +1,44 @@ +import UIKit +import AsyncImageKit + +/// A convenience class for managing image downloads for individual views. +@MainActor +final class SiteMediaImageLoadingController { + var service: MediaImageService = .shared + var onStateChanged: (State) -> Void = { _ in } + + private(set) var task: Task? + + typealias State = ImageLoadingController.State + + deinit { + task?.cancel() + } + + func prepareForReuse() { + task?.cancel() + task = nil + } + + func setImage(with media: Media, size: MediaImageService.ImageSize) { + task?.cancel() + + if let image = service.getCachedThumbnail(for: .init(media), size: size) { + onStateChanged(.success(image)) + } else { + onStateChanged(.loading) + task = Task { @MainActor [service, weak self] in + do { + let image = try await service.image(for: media, size: size) + // This line guarantees that if you cancel on the main thread, + // none of the `onStateChanged` callbacks get called. + guard !Task.isCancelled else { return } + self?.onStateChanged(.success(image)) + } catch { + guard !Task.isCancelled else { return } + self?.onStateChanged(.failure(error)) + } + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/SiteMediaPickerViewController.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/SiteMediaPickerViewController.swift index 169dd9f0b599..f5a57fa21717 100644 --- a/WordPress/Classes/ViewRelated/Media/SiteMedia/SiteMediaPickerViewController.swift +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/SiteMediaPickerViewController.swift @@ -8,6 +8,7 @@ protocol SiteMediaPickerViewControllerDelegate: AnyObject { /// The media picker for your site media. final class SiteMediaPickerViewController: UIViewController, SiteMediaCollectionViewControllerDelegate { private let blog: Blog + private let allowsMultipleSelection: Bool private let initialSelection: [Media] diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/SiteMediaViewController.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/SiteMediaViewController.swift index d6dab88f2dc8..611e566097d2 100644 --- a/WordPress/Classes/ViewRelated/Media/SiteMedia/SiteMediaViewController.swift +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/SiteMediaViewController.swift @@ -285,7 +285,7 @@ final class SiteMediaViewController: UIViewController, SiteMediaCollectionViewCo func siteMediaViewController(_ viewController: SiteMediaCollectionViewController, contextMenuFor media: Media, sourceView: UIView) -> UIMenu? { var actions: [UIAction] = [] - actions.append(UIAction(title: Strings.buttonShare, image: UIImage(systemName: "square.and.arrow.up")) { [weak self] _ in + actions.append(UIAction(title: SharedStrings.Button.share, image: UIImage(systemName: "square.and.arrow.up")) { [weak self] _ in self?.shareSelectedMedia([media], sourceView: sourceView) }) if blog.supports(.mediaDeletion) { @@ -314,7 +314,6 @@ private enum Strings { static let deletionSuccessMessage = NSLocalizedString("mediaLibrary.deletionSuccessMessage", value: "Deleted!", comment: "Text displayed in HUD after successfully deleting a media item") static let deletionFailureMessage = NSLocalizedString("mediaLibrary.deletionFailureMessage", value: "Unable to delete all media items.", comment: "Text displayed in HUD if there was an error attempting to delete a group of media items.") static let sharingFailureMessage = NSLocalizedString("mediaLibrary.sharingFailureMessage", value: "Unable to share the selected items.", comment: "Text displayed in HUD if there was an error attempting to share a group of media items.") - static let buttonShare = NSLocalizedString("mediaLibrary.buttonShare", value: "Share", comment: "Context menu button") static let buttonDelete = NSLocalizedString("mediaLibrary.buttonDelete", value: "Delete", comment: "Context menu button") static let aspectRatioGrid = NSLocalizedString("mediaLibrary.aspectRatioGrid", value: "Aspect Ratio Grid", comment: "Button name in the more menu") static let squareGrid = NSLocalizedString("mediaLibrary.squareGrid", value: "Square Grid", comment: "Button name in the more menu") diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCell.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCell.swift index 54d1b8d7f510..e4c9e8b7fe67 100644 --- a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCell.swift +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaCollectionCell.swift @@ -1,7 +1,7 @@ import UIKit import Combine import Gifu -import WordPressMedia +import AsyncImageKit final class SiteMediaCollectionCell: UICollectionViewCell, Reusable { private let imageContainerView = UIView() diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaImageView.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaImageView.swift new file mode 100644 index 000000000000..cf2e38dedefa --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Views/SiteMediaImageView.swift @@ -0,0 +1,125 @@ +import UIKit +import Gifu +import SwiftUI +import AsyncImageKit +import WordPressUI + +struct SiteMediaImage: UIViewRepresentable { + var media: Media + var size: MediaImageService.ImageSize + private var _loadingStyle = SiteMediaImageView.LoadingStyle.background + + init(media: Media, size: MediaImageService.ImageSize) { + self.media = media + self.size = size + } + + func loadingStyle(_ style: SiteMediaImageView.LoadingStyle) -> SiteMediaImage { + var copy = self + copy._loadingStyle = style + return copy + } + + func makeUIView(context: Context) -> SiteMediaImageView { + SiteMediaImageView() + } + + func updateUIView(_ view: SiteMediaImageView, context: Context) { + view.loadingStyle = _loadingStyle + view.setImage(with: media, size: size) + } +} + +@MainActor +final class SiteMediaImageView: UIView { + private let imageView = GIFImageView() + private var spinner: UIActivityIndicatorView? + private let controller = SiteMediaImageLoadingController() + + /// By default, `background`. + var loadingStyle = LoadingStyle.background + + enum LoadingStyle { + /// Shows a secondary background color during the download. + case background + /// Shows a spinner during the download. + case spinner + } + + /// The currently displayed image. If the image is animated, returns an + /// instance of ``AnimatedImage``. + var image: UIImage? { + didSet { + if let image { + imageView.configure(image: image) + } else { + imageView.prepareForReuse() + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + controller.onStateChanged = { [weak self] in self?.setState($0) } + + addSubview(imageView) + imageView.pinEdges() + + imageView.clipsToBounds = true + imageView.contentMode = .scaleAspectFill + imageView.accessibilityIgnoresInvertColors = true + + backgroundColor = .secondarySystemBackground + } + + /// Removes the current image and stops the outstanding downloads. + func prepareForReuse() { + controller.prepareForReuse() + image = nil + } + + func setImage(with media: Media, size: MediaImageService.ImageSize) { + controller.setImage(with: media, size: size) + } + + private func setState(_ state: ImageLoadingController.State) { + imageView.isHidden = true + spinner?.stopAnimating() + backgroundColor = .clear + + switch state { + case .loading: + switch loadingStyle { + case .background: + backgroundColor = .secondarySystemBackground + case .spinner: + makeSpinner().startAnimating() + } + case .success(let image): + self.image = image + imageView.isHidden = false + case .failure: + break + } + } + + private func makeSpinner() -> UIActivityIndicatorView { + if let spinner { + return spinner + } + let spinner = UIActivityIndicatorView() + addSubview(spinner) + spinner.pinCenter() + self.spinner = spinner + return spinner + } +} diff --git a/WordPress/Classes/ViewRelated/Media/SolidColorActivityIndicator.swift b/WordPress/Classes/ViewRelated/Media/SolidColorActivityIndicator.swift deleted file mode 100644 index 0a8a87876954..000000000000 --- a/WordPress/Classes/ViewRelated/Media/SolidColorActivityIndicator.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation - -final class SolidColorActivityIndicator: UIView, ActivityIndicatorType { - init(color: UIColor = .secondarySystemBackground) { - super.init(frame: .zero) - backgroundColor = color - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func startAnimating() { - isHidden = false - } - - func stopAnimating() { - isHidden = true - } -} diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index e0670fac4936..7761ada2507a 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -1,4 +1,6 @@ import UIKit +import WordPressUI +import AsyncImageKit import AutomatticTracks import GutenbergKit import SafariServices @@ -33,7 +35,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor var analyticsEditorSource: String { Analytics.editorSource } var editorSession: PostEditorAnalyticsSession - var onClose: ((Bool) -> Void)? + var onClose: (() -> Void)? // MARK: - Set content @@ -316,7 +318,7 @@ extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate } func editor(_ viewContoller: GutenbergKit.EditorViewController, didEncounterCriticalError error: any Error) { - onClose?(false) + onClose?() } func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateContentWithState state: GutenbergKit.EditorState) { @@ -453,19 +455,9 @@ extension NewGutenbergViewController { // TODO: are we going to show this natively? func gutenbergDidRequestImagePreview(with fullSizeUrl: URL, thumbUrl: URL?) { - navigationController?.definesPresentationContext = true - - let controller: WPImageViewController - if let image = AnimatedImageCache.shared.cachedStaticImage(url: fullSizeUrl) { - controller = WPImageViewController(image: image) - } else { - controller = WPImageViewController(externalMediaURL: fullSizeUrl) - } - - controller.post = self.post - controller.modalTransitionStyle = .crossDissolve - controller.modalPresentationStyle = .overCurrentContext - self.present(controller, animated: true) + let lightboxVC = LightboxViewController(sourceURL: fullSizeUrl, host: MediaHost(post)) + lightboxVC.configureZoomTransition() + present(lightboxVC, animated: true) } // TODO: reimplement diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationDetailsViewController.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationDetailsViewController.swift index 27a99751b05b..006525b144ff 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationDetailsViewController.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationDetailsViewController.swift @@ -417,12 +417,15 @@ extension NotificationDetailsViewController { replyTextView.accessibilityIdentifier = .replyTextViewAccessibilityId replyTextView.accessibilityLabel = NSLocalizedString("Reply Text", comment: "Notifications Reply Accessibility Identifier") replyTextView.delegate = self - replyTextView.onReply = { [weak self] content in - let group = self?.note.contentGroup(ofKind: .comment) + replyTextView.onReply = { [weak self, weak replyTextView] content in + guard let self, let replyTextView else { + return + } + let group = self.note.contentGroup(ofKind: .comment) guard let block: FormattableCommentContent = group?.blockOfKind(.comment) else { return } - self?.replyCommentWithBlock(block, content: content) + self.replyCommentWithBlock(block, content: content, textView: replyTextView) } replyTextView.setContentCompressionResistancePriority(.required, for: .vertical) @@ -1085,26 +1088,30 @@ private extension NotificationDetailsViewController { _ = navigationController?.popToRootViewController(animated: true) } - func replyCommentWithBlock(_ block: FormattableCommentContent, content: String) { + func replyCommentWithBlock(_ block: FormattableCommentContent, content: String, textView: ReplyTextView) { guard let replyAction = block.action(id: ReplyToCommentAction.actionIdentifier()) else { return } let generator = UINotificationFeedbackGenerator() generator.prepare() - generator.notificationOccurred(.success) let actionContext = ActionContext(block: block, content: content) { [weak self] (request, success) in + textView.setShowingLoadingIndicator(false) if success { + generator.notificationOccurred(.success) WPAppAnalytics.track(.notificationsCommentRepliedTo) + textView.text = "" let message = NSLocalizedString("Reply Sent!", comment: "The app successfully sent a comment") self?.displayNotice(title: message) } else { generator.notificationOccurred(.error) + textView.becomeFirstResponder() self?.displayReplyError(with: block, content: content) } } + textView.setShowingLoadingIndicator(true) replyAction.execute(context: actionContext) } diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingsViewController.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingsViewController.swift index 181ee7c1a0d1..57c6905b1ca6 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingsViewController.swift @@ -331,11 +331,15 @@ private extension NotificationSettingsViewController { labelView.translatesAutoresizingMaskIntoConstraints = false let textProvider = JetpackBrandingTextProvider(screen: JetpackBadgeScreen.notificationsSettings) - let badgeView = JetpackButton.makeBadgeView(title: textProvider.brandingText(), - topPadding: FooterMetrics.jetpackBadgeTopPadding, - bottomPadding: FooterMetrics.jetpackBadgeBottomPatting, - target: self, - selector: #selector(jetpackButtonTapped)) + let badgeView = JetpackButton.makeBadgeView( + title: textProvider.brandingText(), + topPadding: FooterMetrics.jetpackBadgeTopPadding, + bottomPadding: FooterMetrics.jetpackBadgeBottomPatting, + target: self, + selector: #selector( + jetpackButtonTapped + ) + ) badgeView.translatesAutoresizingMaskIntoConstraints = false let stackView = UIStackView(arrangedSubviews: [labelView, badgeView]) diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationTableViewCell.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationTableViewCell.swift index 5cdc7c8ddcf8..f80c7c82b88e 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationTableViewCell.swift @@ -66,7 +66,7 @@ final class NotificationTableViewCell: HostingTableViewCell (image: Image, color: Color?) { let image: Image = Image.DS.icon(named: filled ? .starFill : .starOutline) - let color: Color? = filled ? AppColor.brand: nil + let color: Color? = filled ? AppColor.primary: nil return (image: image, color: color) } @@ -154,12 +154,6 @@ final class NotificationTableViewCell: HostingTableViewCell - + - + @@ -23,7 +23,7 @@ - + @@ -35,7 +35,7 @@ - + @@ -63,16 +63,16 @@ - + - + - + - - - - - - - - - - + + - - + - - - - - - - - + + + + + + + + + + - + - - - - - - - - - - + + + - - - - - - - + - - - + + - + + + - @@ -158,7 +173,7 @@ - + @@ -190,6 +205,7 @@ Nulla sodales mauris ullamcorper massa tincidunt, eu pretium erat fringilla. Sed + @@ -197,7 +213,6 @@ Nulla sodales mauris ullamcorper massa tincidunt, eu pretium erat fringilla. Sed - @@ -216,8 +231,8 @@ Nulla sodales mauris ullamcorper massa tincidunt, eu pretium erat fringilla. Sed - + @@ -231,8 +246,8 @@ Nulla sodales mauris ullamcorper massa tincidunt, eu pretium erat fringilla. Sed - + @@ -250,7 +265,13 @@ Nulla sodales mauris ullamcorper massa tincidunt, eu pretium erat fringilla. Sed - + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Post/Revisions/Browser/RevisionsNavigationController.swift b/WordPress/Classes/ViewRelated/Post/Revisions/Browser/RevisionsNavigationController.swift index 1a48be593868..dd9c9e81b6a7 100644 --- a/WordPress/Classes/ViewRelated/Post/Revisions/Browser/RevisionsNavigationController.swift +++ b/WordPress/Classes/ViewRelated/Post/Revisions/Browser/RevisionsNavigationController.swift @@ -1,3 +1,5 @@ +import UIKit + class RevisionsNavigationController: UINavigationController { var revisionState: RevisionBrowserState? { didSet { @@ -8,8 +10,7 @@ class RevisionsNavigationController: UINavigationController { override func viewDidLoad() { super.viewDidLoad() - navigationBar.setBackgroundImage(UIImage(color: UIAppColor.neutral(.shade70)), for: .default) - navigationBar.shadowImage = UIImage(color: UIAppColor.neutral(.shade60)) + view.backgroundColor = .systemBackground // important as the content view go below it } private func setupForBrowserState() { diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/LightNavigationController.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/LightNavigationController.swift deleted file mode 100644 index 6a8937bf1c91..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/LightNavigationController.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -// TODO: remove - -// A Navigation Controller with a light navigation bar style -class LightNavigationController: UINavigationController { -} diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/PublishDatePickerViewController.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/PublishDatePickerViewController.swift index 72c8ce100f3d..e2b6844ae583 100644 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/PublishDatePickerViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Scheduling/PublishDatePickerViewController.swift @@ -73,7 +73,7 @@ struct PublishDatePickerView: View { .environment(\.defaultMinListHeaderHeight, 0) .navigationTitle(Strings.title) .navigationBarTitleDisplayMode(.inline) - .tint(Color(uiColor: UIAppColor.brand)) + .tint(Color(uiColor: UIAppColor.primary)) } private var dateRow: some View { diff --git a/WordPress/Classes/ViewRelated/Post/Utils/PostNoticeNavigationCoordinator.swift b/WordPress/Classes/ViewRelated/Post/Utils/PostNoticeNavigationCoordinator.swift index ad536f6b5194..8168ed23bf60 100644 --- a/WordPress/Classes/ViewRelated/Post/Utils/PostNoticeNavigationCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Post/Utils/PostNoticeNavigationCoordinator.swift @@ -28,7 +28,7 @@ class PostNoticeNavigationCoordinator { controller.trackOpenEvent() controller.navigationItem.title = NSLocalizedString("View", comment: "Verb. The screen title shown when viewing a post inside the app.") - let navigationController = LightNavigationController(rootViewController: controller) + let navigationController = UINavigationController(rootViewController: controller) if presenter.traitCollection.userInterfaceIdiom == .pad { navigationController.modalPresentationStyle = .fullScreen } diff --git a/WordPress/Classes/ViewRelated/Post/Utils/PostNoticePublishSuccessView.swift b/WordPress/Classes/ViewRelated/Post/Utils/PostNoticePublishSuccessView.swift index 6b7e22e75f8f..0b3c259ed034 100644 --- a/WordPress/Classes/ViewRelated/Post/Utils/PostNoticePublishSuccessView.swift +++ b/WordPress/Classes/ViewRelated/Post/Utils/PostNoticePublishSuccessView.swift @@ -117,7 +117,7 @@ struct PostNoticePublishSuccessView: View { WPAnalytics.track(.postEpilogueView) let controller = PreviewWebKitViewController(post: post, source: "edit_post_preview") controller.trackOpenEvent() - let navWrapper = LightNavigationController(rootViewController: controller) + let navWrapper = UINavigationController(rootViewController: controller) presenter.present(navWrapper, animated: true) } diff --git a/WordPress/Classes/ViewRelated/Post/Views/AbstractPostHelper+Actions.swift b/WordPress/Classes/ViewRelated/Post/Views/AbstractPostHelper+Actions.swift index 6a3a49b00616..77df9cddc1b9 100644 --- a/WordPress/Classes/ViewRelated/Post/Views/AbstractPostHelper+Actions.swift +++ b/WordPress/Classes/ViewRelated/Post/Views/AbstractPostHelper+Actions.swift @@ -42,7 +42,7 @@ extension AbstractPostHelper { actions.append(trashAction) if post is Post, post.status == .publish && post.hasRemote() { - let shareAction = UIContextualAction(style: .normal, title: Strings.swipeActionShare) { [weak delegate] _, view, completion in + let shareAction = UIContextualAction(style: .normal, title: SharedStrings.Button.share) { [weak delegate] _, view, completion in delegate?.share(post, fromView: view) completion(true) } @@ -56,7 +56,6 @@ extension AbstractPostHelper { private enum Strings { static let swipeActionView = NSLocalizedString("postList.swipeActionView", value: "View", comment: "Title for the 'View' post list row swipe action") - static let swipeActionShare = NSLocalizedString("postList.swipeActionShare", value: "Share", comment: "Title for the 'Share' post list row swipe action") static let swipeActionTrash = NSLocalizedString("postList.swipeActionDelete", value: "Trash", comment: "Title for the 'Trash' post list row swipe action") static let swipeActionDeletePermanently = NSLocalizedString("postList.swipeActionDeletePermanently", value: "Delete", comment: "Title for the 'Delete' post list row swipe action") } diff --git a/WordPress/Classes/ViewRelated/Post/Views/AbstractPostMenuHelper.swift b/WordPress/Classes/ViewRelated/Post/Views/AbstractPostMenuHelper.swift index 43f7d5f69cb8..62848d4ff898 100644 --- a/WordPress/Classes/ViewRelated/Post/Views/AbstractPostMenuHelper.swift +++ b/WordPress/Classes/ViewRelated/Post/Views/AbstractPostMenuHelper.swift @@ -130,7 +130,7 @@ extension AbstractPostButton: AbstractPostMenuAction { case .trash: return Strings.trash case .delete: return Strings.delete case .retry: return Strings.retry - case .share: return Strings.share + case .share: return SharedStrings.Button.share case .blaze: return Strings.blaze case .comments: return Strings.comments case .settings: return Strings.settings @@ -191,7 +191,6 @@ extension AbstractPostButton: AbstractPostMenuAction { static let view = NSLocalizedString("posts.view.actionTitle", value: "View", comment: "Label for the view post button. Tapping displays the post as it appears on the web.") static let preview = NSLocalizedString("posts.preview.actionTitle", value: "Preview", comment: "Label for the preview post button. Tapping displays the post as it appears on the web.") static let publish = NSLocalizedString("posts.publish.actionTitle", value: "Publish", comment: "Label for the publish post button.") - static let share = NSLocalizedString("posts.share.actionTitle", value: "Share", comment: "Share the post.") static let blaze = NSLocalizedString("posts.blaze.actionTitle", value: "Promote with Blaze", comment: "Promote the post with Blaze.") static let setHomepage = NSLocalizedString("posts.setHomepage.actionTitle", value: "Set as homepage", comment: "Set the selected page as the homepage.") static let setPostsPage = NSLocalizedString("posts.setPostsPage.actionTitle", value: "Set as posts page", comment: "Set the selected page as a posts page.") diff --git a/WordPress/Classes/ViewRelated/Post/Views/PostCompactCell.swift b/WordPress/Classes/ViewRelated/Post/Views/PostCompactCell.swift index 5d5229ffc34f..6d4d2e5c5949 100644 --- a/WordPress/Classes/ViewRelated/Post/Views/PostCompactCell.swift +++ b/WordPress/Classes/ViewRelated/Post/Views/PostCompactCell.swift @@ -2,7 +2,7 @@ import AutomatticTracks import UIKit import WordPressShared import WordPressUI -import WordPressMedia +import AsyncImageKit final class PostCompactCell: UITableViewCell, Reusable { private let titleLabel = UILabel() @@ -78,12 +78,8 @@ final class PostCompactCell: UITableViewCell, Reusable { if let post, let url = post.featuredImageURL { featuredImageView.isHidden = false - let host = MediaHost(with: post, failure: { error in - // We'll log the error, so we know it's there, but we won't halt execution. - WordPressAppDelegate.crashLogging?.logError(error) - }) - - let targetSize = Constants.imageSize.scaled(by: traitCollection.displayScale) + let host = MediaHost(post) + let targetSize = ImageSize(scaling: Constants.imageSize, in: self) featuredImageView.setImage(with: url, host: host, size: targetSize) } else { featuredImageView.isHidden = true diff --git a/WordPress/Classes/ViewRelated/Post/Views/PostListCell.swift b/WordPress/Classes/ViewRelated/Post/Views/PostListCell.swift index 6d68e35f1264..431b482a3d9f 100644 --- a/WordPress/Classes/ViewRelated/Post/Views/PostListCell.swift +++ b/WordPress/Classes/ViewRelated/Post/Views/PostListCell.swift @@ -1,6 +1,6 @@ import Foundation import UIKit -import WordPressMedia +import AsyncImageKit protocol AbstractPostListCell { /// A post displayed by the cell. @@ -75,9 +75,7 @@ final class PostListCell: UITableViewCell, AbstractPostListCell, PostSearchResul featuredImageView.isHidden = viewModel.imageURL == nil featuredImageView.layer.opacity = viewModel.syncStateViewModel.isEditable ? 1 : 0.25 if let imageURL = viewModel.imageURL { - let host = MediaHost(with: viewModel.post) { error in - WordPressAppDelegate.crashLogging?.logError(error) - } + let host = MediaHost(viewModel.post) let thumbnailURL = MediaImageService.getResizedImageURL(for: imageURL, blog: viewModel.post.blog, size: Constants.imageSize.scaled(by: UIScreen.main.scale)) featuredImageView.setImage(with: thumbnailURL, host: host) } diff --git a/WordPress/Classes/ViewRelated/Post/Views/PostMediaUploadsView.swift b/WordPress/Classes/ViewRelated/Post/Views/PostMediaUploadsView.swift index 500a0e0ca2bc..1268760cf984 100644 --- a/WordPress/Classes/ViewRelated/Post/Views/PostMediaUploadsView.swift +++ b/WordPress/Classes/ViewRelated/Post/Views/PostMediaUploadsView.swift @@ -133,7 +133,7 @@ struct MediaUploadProgressView: View { .stroke(Color.secondary.opacity(0.25), lineWidth: 2) Circle() .trim(from: 0, to: progress) - .stroke(Color(uiColor: UIAppColor.brand), style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .stroke(Color(uiColor: UIAppColor.primary), style: StrokeStyle(lineWidth: 2, lineCap: .round)) .rotationEffect(.degrees(-90)) .animation(.easeOut, value: progress) } diff --git a/WordPress/Classes/ViewRelated/Post/Views/PostSettingsFeaturedImageCell.swift b/WordPress/Classes/ViewRelated/Post/Views/PostSettingsFeaturedImageCell.swift new file mode 100644 index 000000000000..fb905a64c741 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Views/PostSettingsFeaturedImageCell.swift @@ -0,0 +1,194 @@ +import SwiftUI +import AsyncImageKit +import WordPressUI + +struct PostSettingsFeaturedImageCell: View { + @ObservedObject var post: AbstractPost + @ObservedObject var viewModel: PostSettingsFeaturedImageViewModel + + var onViewTapped: () -> Void + + var body: some View { + if let image = post.featuredImage { + SiteMediaImage(media: image, size: .large) + .loadingStyle(.spinner) + .accessibilityIdentifier("featured_image_current_image") + .aspectRatio(1.0 / ReaderPostCell.coverAspectRatio, contentMode: .fit) + .overlay { + menu + } + .contextMenu { + actions + } + } else { + if viewModel.upload != nil { + // The upload state when no image is selected. For the "Replace" + // flow, the app shows the upload differently (see `menu`). + uploading + } else { + makeMediaPicker { + Label(Strings.buttonSetFeaturedImage, systemImage: "photo.badge.plus") + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) // Make the whole cell tappable + } + } + } + } + + private var menu: some View { + Menu { + actions + } label: { + ZStack { + Circle() + .foregroundStyle(Color(.secondarySystemBackground)) + .frame(width: 30, height: 30) + if viewModel.upload != nil { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "ellipsis") + .foregroundStyle(Color(.label)) + .font(.system(size: 18)) + } + } + .shadow(color: .black.opacity(0.5), radius: 10) + .padding(12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + } + } + + @ViewBuilder + private var actions: some View { + if viewModel.upload == nil { + Button(SharedStrings.Button.view, systemImage: "plus.magnifyingglass", action: onViewTapped) + .accessibilityIdentifier("featured_image_button_view") + makeMediaPicker { + Button(Strings.replaceImage, systemImage: "photo.badge.plus", action: onViewTapped) + .accessibilityIdentifier("featured_image_button_replace") + } + Button(SharedStrings.Button.remove, systemImage: "trash", role: .destructive, action: viewModel.buttonRemoveTapped) + .accessibilityIdentifier("featured_image_button_remove") + } else { + Button(role: .destructive, action: viewModel.buttonCancelTapped) { + Label(Strings.cancelUpload, systemImage: "trash") + } + } + } + + private var uploading: some View { + HStack(alignment: .center, spacing: 0) { + ProgressView() + .padding(.trailing, 12) + + Text(Strings.uploading) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer(minLength: 8) + + Menu { + Button(role: .destructive, action: viewModel.buttonCancelTapped) { + Label(Strings.cancelUpload, systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis") + .font(.subheadline) + .tint(.secondary) + } + } + } + + private func makeMediaPicker(@ViewBuilder content: @escaping () -> Content) -> some View { + let configuration = MediaPickerConfiguration( + sources: [.photos, .camera, .playground, .siteMedia(blog: post.blog)], + filter: .images + ) + return MediaPicker(configuration: configuration, onSelection: viewModel.setFeaturedImage) { + content() + } + } +} + +final class PostSettingsFeaturedImageViewModel: NSObject, ObservableObject { + @Published private(set) var upload: Media? + + let post: AbstractPost + + private var receipt: UUID? + private let coordinator = MediaCoordinator.shared + + @objc weak var tableView: UITableView? + @objc weak var delegate: FeaturedImageDelegate? + + @objc init(post: AbstractPost) { + self.post = post + } + + func setFeaturedImage(selection: MediaPickerSelection) { + WPAnalytics.track(.editorPostFeaturedImageChanged, properties: ["via": "settings", "action": "added", "source": selection.source]) + + guard let item = selection.items.first else { + return wpAssertionFailure("selection is empty") + } + switch item.exported() { + case .asset(let exportableAsset): + guard let media = coordinator.addMedia(from: exportableAsset, to: post) else { + return wpAssertionFailure("failed to add media to post") + } + self.receipt = coordinator.addObserver({ [weak self] media, state in + self?.didUpdateUploadState(state, media: media) + }, for: media) + self.upload = media + case .media(let media): + didProcessMedia(media) + } + } + + private func didUpdateUploadState(_ state: MediaCoordinator.MediaState, media: Media) { + switch state { + case .ended: + didProcessMedia(media) + case .failed(let error): + Notice(title: Strings.uploadFailed, message: error.localizedDescription).post() + upload = nil + default: + break + } + } + + private func didProcessMedia(_ media: Media) { + wpAssert(media.remoteURL != nil) + + upload = nil + setFeaturedImage(media) + } + func buttonCancelTapped() { + guard let upload else { return } + coordinator.cancelUploadAndDeleteMedia(upload) + self.upload = nil + } + + func buttonRemoveTapped() { + WPAnalytics.track(.editorPostFeaturedImageChanged, properties: ["via": "settings", "action": "removed"]) + + setFeaturedImage(nil) + } + + private func setFeaturedImage(_ media: Media?) { + upload = nil + post.featuredImage = media + delegate?.gutenbergDidRequestFeaturedImageId(media?.mediaID ?? GutenbergFeaturedImageHelper.mediaIdNoFeaturedImageSet as NSNumber) + UIView.performWithoutAnimation { + tableView?.reloadData() + } + } +} + +private enum Strings { + static let buttonSetFeaturedImage = NSLocalizedString("postSettings.featuredImage.setFeaturedImageButton", value: "Set Featured Image", comment: "Button in Post Settings") + static let uploading = NSLocalizedString("postSettings.featuredImage.uploading", value: "Uploading…", comment: "Post Settings") + static let cancelUpload = NSLocalizedString("postSettings.featuredImage.cancelUpload", value: "Cancel Upload", comment: "Cancel upload button in Post Settings / Featured Image cell") + static let replaceImage = NSLocalizedString("postSettings.featuredImage.replaceImage", value: "Replace", comment: "Replace image upload button in Post Settings / Featured Image cell") + static let uploadFailed = NSLocalizedString("postSettings.featuredImage.uploadFailed", value: "Failed to upload new featured image", comment: "Snackbar title") +} diff --git a/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginCoordinator.swift b/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginCoordinator.swift index e3cdb57c6bb9..c21a7c2a8b28 100644 --- a/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginCoordinator.swift +++ b/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginCoordinator.swift @@ -18,13 +18,13 @@ struct QRLoginCoordinator: QRLoginParentCoordinator { static func didHandle(url: URL) -> Bool { guard - let token = QRLoginURLParser(urlString: url.absoluteString).parse(), + let _ = QRLoginURLParser(urlString: url.absoluteString).parse(), let source = UIApplication.shared.leafViewController else { return false } - - self.init(origin: .deepLink).showVerifyAuthorization(token: token, from: source) + self.init(origin: .deepLink).showCameraScanningView(from: source) + Notice(title: Strings.scanFromApp).post() return true } @@ -34,10 +34,7 @@ struct QRLoginCoordinator: QRLoginParentCoordinator { func showVerifyAuthorization(token: QRLoginToken, from source: UIViewController? = nil) { let controller = QRLoginVerifyAuthorizationViewController() - controller.coordinator = QRLoginVerifyCoordinator(token: token, - view: controller, - parentCoordinator: self) - + controller.coordinator = QRLoginVerifyCoordinator(token: token, view: controller, parentCoordinator: self) pushOrPresent(controller, from: source) } } @@ -115,3 +112,7 @@ extension QRLoginCoordinator { QRLoginCoordinator(origin: origin).showVerifyAuthorization(token: token, from: source) } } + +private enum Strings { + static let scanFromApp = NSLocalizedString("qrLogin.codeHasToBeScannedFromTheAppNotice.title", value: "Please scan the code using the app", comment: "Informational notice title. Showed when you scan a code using a camera app outside of the app, which is not allowed.") +} diff --git a/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginScanningCoordinator.swift b/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginScanningCoordinator.swift index c1efd3c2ec44..2a13ded14863 100644 --- a/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginScanningCoordinator.swift +++ b/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginScanningCoordinator.swift @@ -4,6 +4,7 @@ class QRLoginScanningCoordinator: NSObject { let parentCoordinator: QRLoginParentCoordinator let view: QRLoginScanningView var cameraSession: QRCodeScanningSession + private var didHandleToken = false init(view: QRLoginScanningView, parentCoordinator: QRLoginParentCoordinator, cameraSession: QRCodeScanningSession = QRLoginCameraSession()) { self.view = view @@ -48,6 +49,9 @@ extension QRLoginScanningCoordinator { } func didScanToken(_ token: QRLoginToken) { + guard !didHandleToken else { return } + didHandleToken = true // Prevents the subsequent captures. + parentCoordinator.track(.qrLoginScannerScannedCode) // Give the user a tap to let them know they've successfully scanned the code diff --git a/WordPress/Classes/ViewRelated/Reader/Cards/ReaderCrossPostCell.swift b/WordPress/Classes/ViewRelated/Reader/Cards/ReaderCrossPostCell.swift index 1f3fa9f6e941..923c06598f19 100644 --- a/WordPress/Classes/ViewRelated/Reader/Cards/ReaderCrossPostCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/Cards/ReaderCrossPostCell.swift @@ -2,6 +2,7 @@ import Foundation import AutomatticTracks import WordPressShared import WordPressUI +import AsyncImageKit final class ReaderCrossPostCell: ReaderStreamBaseCell { private let view = ReaderCrossPostView() @@ -132,8 +133,7 @@ private final class ReaderCrossPostView: UIView { avatarView.setPlaceholder(UIImage(named: "post-blavatar-placeholder")) if let avatarURL = post.avatarURLForDisplay() { - let avatarSize = CGSize(width: avatarSize, height: avatarSize) - .scaled(by: UITraitCollection.current.displayScale) + let avatarSize = ImageSize(scaling: CGSize(width: avatarSize, height: avatarSize)) avatarView.setImage(with: avatarURL, size: avatarSize) } } diff --git a/WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCell.swift b/WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCell.swift index c1b28ec48686..1c616f3cbb70 100644 --- a/WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCell.swift @@ -1,7 +1,9 @@ import SwiftUI import UIKit import Combine +import WordPressUI import WordPressShared +import AsyncImageKit final class ReaderPostCell: ReaderStreamBaseCell { private let view = ReaderPostCellView() @@ -50,6 +52,18 @@ final class ReaderPostCell: ReaderStreamBaseCell { contentViewConstraints = view.pinEdges(.horizontal, to: isCompact ? contentView : contentView.readableContentGuide) super.updateConstraints() } + + static func preferredCoverSize(in window: UIWindow, isCompact: Bool) -> ImageSize { + var coverWidth = ReaderPostCell.regularCoverWidth + if isCompact { + coverWidth = min(window.bounds.width, window.bounds.height) - ReaderStreamBaseCell.insets.left * 2 + } + return ImageSize(scaling: CGSize(width: coverWidth, height: coverWidth), in: window) + } + + func getViewForZoomTransition() -> UIView { + view + } } private final class ReaderPostCellView: UIView { @@ -57,6 +71,7 @@ private final class ReaderPostCellView: UIView { let avatarView = ReaderAvatarView() let buttonAuthor = makeAuthorButton() let timeLabel = UILabel() + let seenCheckmark = UIImageView() let buttonMore = makeButton(systemImage: "ellipsis", font: .systemFont(ofSize: 13)) // Content @@ -86,6 +101,7 @@ private final class ReaderPostCellView: UIView { private var toolbarViewHeightConstraint: NSLayoutConstraint? private var imageViewConstraints: [NSLayoutConstraint] = [] + private var isSeenCheckmarkConfigured = false private var cancellables: [AnyCancellable] = [] override init(frame: CGRect) { @@ -130,7 +146,7 @@ private final class ReaderPostCellView: UIView { imageView.layer.masksToBounds = true imageView.contentMode = .scaleAspectFill - buttonMore.configuration?.baseForegroundColor = UIColor.opaqueSeparator + buttonMore.configuration?.baseForegroundColor = UIColor.secondaryLabel.withAlphaComponent(0.5) buttonMore.configuration?.contentInsets = .init(top: 12, leading: 8, bottom: 12, trailing: 20) } @@ -141,7 +157,8 @@ private final class ReaderPostCellView: UIView { // These seems to be an issue with `lineBreakMode` in `UIButton.Configuration` // and `.firstLineBaseline`, so reserving to `.center`. - let headerView = UIStackView(alignment: .center, [buttonAuthor, dot, timeLabel]) + let headerView = UIStackView(alignment: .center, [buttonAuthor, dot, timeLabel, seenCheckmark]) + headerView.setCustomSpacing(4, after: timeLabel) for view in [avatarView, headerView, postPreview, buttonMore, toolbarView] { addSubview(view) @@ -154,7 +171,7 @@ private final class ReaderPostCellView: UIView { avatarView.widthAnchor.constraint(equalToConstant: ReaderPostCell.avatarSize), avatarView.heightAnchor.constraint(equalToConstant: ReaderPostCell.avatarSize), avatarView.centerYAnchor.constraint(equalTo: timeLabel.centerYAnchor), - avatarView.trailingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: -8), + avatarView.trailingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: -9), headerView.topAnchor.constraint(equalTo: topAnchor, constant: 6), headerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: insets.left), @@ -294,6 +311,13 @@ private final class ReaderPostCellView: UIView { imageView.setImage(with: imageURL, size: preferredCoverSize) } + if viewModel.isSeen == true { + configureSeenCheckmarkIfNeeded() + seenCheckmark.isHidden = false + } else { + seenCheckmark.isHidden = true + } + if !viewModel.isToolbarHidden { configureToolbar(with: viewModel.toolbar) configureToolbarAccessibility(with: viewModel.toolbar) @@ -305,25 +329,16 @@ private final class ReaderPostCellView: UIView { } } - private var preferredCoverSize: CGSize? { + private var preferredCoverSize: ImageSize? { guard let window = window ?? UIApplication.shared.mainWindow else { return nil } - return Self.preferredCoverSize(in: window, isCompact: isCompact) - } - - static func preferredCoverSize(in window: UIWindow, isCompact: Bool) -> CGSize { - var coverWidth = ReaderPostCell.regularCoverWidth - if isCompact { - coverWidth = min(window.bounds.width, window.bounds.height) - ReaderStreamBaseCell.insets.left * 2 - } - return CGSize(width: coverWidth, height: coverWidth) - .scaled(by: min(2, window.traitCollection.displayScale)) + return ReaderPostCell.preferredCoverSize(in: window, isCompact: isCompact) } private func configureToolbar(with viewModel: ReaderPostToolbarViewModel) { buttons.bookmark.configuration = { var configuration = buttons.bookmark.configuration ?? .plain() configuration.image = UIImage(systemName: viewModel.isBookmarked ? "bookmark.fill" : "bookmark") - configuration.baseForegroundColor = viewModel.isBookmarked ? UIAppColor.brand : .secondaryLabel + configuration.baseForegroundColor = viewModel.isBookmarked ? UIAppColor.primary : .secondaryLabel return configuration }() @@ -345,8 +360,7 @@ private final class ReaderPostCellView: UIView { private func setAvatar(with viewModel: ReaderPostCellViewModel) { avatarView.setPlaceholder(UIImage(named: "post-blavatar-placeholder")) - let avatarSize = CGSize(width: ReaderPostCell.avatarSize, height: ReaderPostCell.avatarSize) - .scaled(by: UITraitCollection.current.displayScale) + let avatarSize = ImageSize(scaling: CGSize(width: ReaderPostCell.avatarSize, height: ReaderPostCell.avatarSize)) if let avatarURL = viewModel.avatarURL { avatarView.setImage(with: avatarURL, size: avatarSize) } else { @@ -356,6 +370,17 @@ private final class ReaderPostCellView: UIView { } } + private func configureSeenCheckmarkIfNeeded() { + guard !isSeenCheckmarkConfigured else { return } + isSeenCheckmarkConfigured = true + + seenCheckmark.image = UIImage( + systemName: "checkmark", + withConfiguration: UIImage.SymbolConfiguration(font: .preferredFont(forTextStyle: .caption1).withWeight(.medium)) + ) + seenCheckmark.tintColor = .secondaryLabel + } + private static let authorAttributes = AttributeContainer([ .font: WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .medium), .foregroundColor: UIColor.label diff --git a/WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCellViewModel.swift b/WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCellViewModel.swift index 0600cf4b61e4..8f6c24e1e6d6 100644 --- a/WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCellViewModel.swift +++ b/WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCellViewModel.swift @@ -1,5 +1,5 @@ import Foundation -import WordPressMedia +import AsyncImageKit final class ReaderPostCellViewModel { // Header @@ -10,6 +10,7 @@ final class ReaderPostCellViewModel { // Content let title: String let details: String + let isSeen: Bool? let imageURL: URL? // Footer (Buttons) @@ -34,9 +35,10 @@ final class ReaderPostCellViewModel { } else { self.author = post.blogNameForDisplay() ?? "" } - self.time = post.dateForDisplay()?.toShortString() ?? "–" + self.time = post.dateForDisplay()?.toShortString() ?? "" self.title = post.titleForDisplay() ?? "" self.details = post.contentPreviewForDisplay() ?? "" + self.isSeen = post.isSeenSupported ? post.isSeen : nil self.imageURL = post.featuredImageURLForDisplay() self.toolbar = ReaderPostToolbarViewModel.make(post: post) @@ -64,6 +66,7 @@ final class ReaderPostCellViewModel { self.avatarURL = URL(string: "https://picsum.photos/120/120.jpg") self.author = "WordPress Mobile Apps" self.time = "9d ago" + self.isSeen = nil self.title = "Discovering the Wonders of the Wild" self.details = "Lorem ipsum dolor sit amet. Non omnis quia et natus voluptatum et eligendi voluptate vel iusto fuga sit repellendus molestiae aut voluptatem blanditiis ad neque sapiente. Id galisum distinctio quo enim aperiam non veritatis vitae et ducimus rerum." self.imageURL = URL(string: "https://picsum.photos/1260/630.jpg") diff --git a/WordPress/Classes/ViewRelated/Reader/Cards/ReaderRecommendedSitesCell.swift b/WordPress/Classes/ViewRelated/Reader/Cards/ReaderRecommendedSitesCell.swift index 0d74bee0019d..462773d33323 100644 --- a/WordPress/Classes/ViewRelated/Reader/Cards/ReaderRecommendedSitesCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/Cards/ReaderRecommendedSitesCell.swift @@ -72,7 +72,7 @@ private final class ReaderRecommendedSitesCellView: UIView { let buttonSubscribe = UIButton(configuration: { var configuration = UIButton.Configuration.plain() configuration.image = UIImage(systemName: "plus.circle") - configuration.baseForegroundColor = UIAppColor.brand + configuration.baseForegroundColor = UIAppColor.primary configuration.contentInsets = .zero return configuration }()) @@ -160,7 +160,7 @@ private final class ReaderRecommendedSitesCellView: UIView { buttonSubscribe.configuration?.baseForegroundColor = .secondaryLabel ReaderSubscriptionHelper().toggleFollowingForSite(site) { [weak self] _ in self?.buttonSubscribe.configuration?.showsActivityIndicator = false - self?.buttonSubscribe.configuration?.baseForegroundColor = UIAppColor.brand + self?.buttonSubscribe.configuration?.baseForegroundColor = UIAppColor.primary } } } diff --git a/WordPress/Classes/ViewRelated/Reader/Cards/ReaderStreamBaseCell.swift b/WordPress/Classes/ViewRelated/Reader/Cards/ReaderStreamBaseCell.swift index 931aa1a20032..149264301cbd 100644 --- a/WordPress/Classes/ViewRelated/Reader/Cards/ReaderStreamBaseCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/Cards/ReaderStreamBaseCell.swift @@ -1,7 +1,7 @@ import UIKit class ReaderStreamBaseCell: UITableViewCell { - static let insets = UIEdgeInsets(top: 0, left: 44, bottom: 0, right: 16) + static let insets = UIEdgeInsets(top: 0, left: 46, bottom: 0, right: 16) var isCompact: Bool = true { didSet { diff --git a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.m b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.m index d7b27018dace..e0610eaedf52 100644 --- a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.m +++ b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.m @@ -5,7 +5,6 @@ #import "ReaderPost.h" #import "ReaderPostService.h" #import "UIView+Subviews.h" -#import "WPImageViewController.h" #import "WPTableViewHandler.h" #import "SuggestionsTableView.h" #import "WordPress-Swift.h" @@ -917,6 +916,9 @@ - (void)sendReplyWithNewContent:(NSString *)content NSString *successMessage = NSLocalizedString(@"Reply Sent!", @"The app successfully sent a comment"); [weakSelf displayNoticeWithTitle:successMessage message:nil]; + [weakSelf.replyTextView setShowingLoadingIndicator:NO]; + weakSelf.replyTextView.text = @""; + [weakSelf trackReplyTo:replyToComment]; [weakSelf.tableView deselectSelectedRowWithAnimation:YES]; [weakSelf refreshReplyTextViewPlaceholder]; @@ -932,11 +934,16 @@ - (void)sendReplyWithNewContent:(NSString *)content DDLogError(@"Error sending reply: %@", error); [generator notificationOccurred:UINotificationFeedbackTypeError]; NSString *message = NSLocalizedString(@"There has been an unexpected error while sending your reply", "Reply Failure Message"); - [weakSelf displayNoticeWithTitle:message message:nil]; + [weakSelf.replyTextView setShowingLoadingIndicator:NO]; + [weakSelf displayNoticeWithTitle:message message:[error localizedDescription]]; + + [weakSelf.replyTextView becomeFirstResponder]; [weakSelf refreshTableViewAndNoResultsView:NO]; }; + [self.replyTextView setShowingLoadingIndicator:YES]; + CommentService *service = [[CommentService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; if (replyToComment) { @@ -1269,30 +1276,12 @@ - (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRan - (void)richContentView:(WPRichContentView *)richContentView didReceiveImageAction:(WPRichTextImage *)image { - UIViewController *controller = nil; - BOOL isSupportedNatively = [WPImageViewController isUrlSupported:image.linkURL]; - - if (image.imageView.animatedGifData) { - controller = [[WPImageViewController alloc] initWithGifData:image.imageView.animatedGifData]; - } else if (isSupportedNatively) { - controller = [[WPImageViewController alloc] initWithImage:image.imageView.image andURL:image.linkURL]; - } else if (image.linkURL) { - [self presentWebViewControllerWithURL:image.linkURL]; - return; - } else if (image.imageView.image) { - controller = [[WPImageViewController alloc] initWithImage:image.imageView.image]; - } - - if (controller) { - controller.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; - controller.modalPresentationStyle = UIModalPresentationFullScreen; - [self presentViewController:controller animated:YES completion:nil]; - } + [self showFullScreenImage:image from:richContentView]; } - (void)interactWithURL:(NSURL *)URL { - [self presentWebViewControllerWithURL:URL]; + [self presentWebViewControllerWith:URL]; } - (BOOL)richContentViewShouldUpdateLayoutForAttachments:(WPRichContentView *)richContentView @@ -1310,22 +1299,6 @@ - (void)richContentViewDidUpdateLayoutForAttachments:(WPRichContentView *)richCo [self updateTableViewForAttachments]; } -- (void)presentWebViewControllerWithURL:(NSURL *)URL -{ - NSURL *linkURL = URL; - NSURLComponents *components = [NSURLComponents componentsWithString:[URL absoluteString]]; - if (!components.host) { - linkURL = [components URLRelativeToURL:[NSURL URLWithString:self.post.blogURL]]; - } - - WebViewControllerConfiguration *configuration = [[WebViewControllerConfiguration alloc] initWithUrl:linkURL]; - [configuration authenticateWithDefaultAccount]; - [configuration setAddsWPComReferrer:YES]; - UIViewController *webViewController = [WebViewControllerFactory controllerWithConfiguration:configuration source:@"reader_comments"]; - UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:webViewController]; - [self presentViewController:navController animated:YES completion:nil]; -} - - (void)textViewDidChangeSelection:(UITextView *)textView { if (!textView.selectedTextRange.isEmpty) { diff --git a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift index 53fe1a417810..42d0ae5f8fe0 100644 --- a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift @@ -8,7 +8,7 @@ extension NSNotification.Name { static let ReaderCommentModifiedNotification = NSNotification.Name(rawValue: "ReaderCommentModifiedNotification") } -@objc public extension ReaderCommentsViewController { +@objc extension ReaderCommentsViewController { func shouldShowSuggestions(for siteID: NSNumber?) -> Bool { guard let siteID, let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { return false } return SuggestionService.shared.shouldShowSuggestions(for: blog) @@ -26,6 +26,32 @@ extension NSNotification.Name { navigationController?.pushViewController(controller, animated: true) } + @objc func showFullScreenImage(_ image: WPRichTextImage, from contentView: WPRichContentView) { + if let contentURL = image.contentURL { + let lightboxVC = LightboxViewController(sourceURL: contentURL) + lightboxVC.configureZoomTransition() + present(lightboxVC, animated: true) + } else if let linkURL = image.linkURL { + presentWebViewController(with: linkURL) + } + } + + @objc func presentWebViewController(with url: URL) { + var linkURL = url + if let components = URLComponents(string: url.absoluteString), components.host == nil { + linkURL = components.url(relativeTo: URL(string: self.post.blogURL)) ?? linkURL + } + let configuration = WebViewControllerConfiguration(url: linkURL) + configuration.authenticateWithDefaultAccount() + configuration.addsWPComReferrer = true + let webVC = WebViewControllerFactory.controller( + configuration: configuration, + source: "reader_comments" + ) + let navigationVC = UINavigationController(rootViewController: webVC) + self.present(navigationVC, animated: true, completion: nil) + } + // MARK: New Comment Threads func configuredHeaderView(for tableView: UITableView) -> UIView { @@ -351,7 +377,7 @@ enum ReaderCommentMenu { case .edit: return NSLocalizedString("Edit", comment: "Edits the comment") case .share: - return NSLocalizedString("Share", comment: "Shares the comment URL") + return SharedStrings.Button.share } } diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderDiscoverViewController.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderDiscoverViewController.swift index 29027b99b8f2..dad7bd1eb645 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderDiscoverViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderDiscoverViewController.swift @@ -127,7 +127,10 @@ class ReaderDiscoverViewController: UIViewController, ReaderDiscoverHeaderViewDe streamVC.view.pinEdges() streamVC.didMove(toParent: self) - navigationItem.titleView = streamVC.navigationItem.titleView // important + streamVC.titleView.detailsLabel.text = selectedChannel.localizedTitle + streamVC.titleView.detailsLabel.isHidden = false + + navigationItem.titleView = streamVC.titleView // important } /// TODO: (tech-debt) the app currently stores the responses from the `/discover` diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderFeedCell.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderFeedCell.swift index 68be211e6f0b..6b90262ebb00 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderFeedCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderFeedCell.swift @@ -1,4 +1,5 @@ import SwiftUI +import WordPressUI import WordPressKit struct ReaderFeedCell: View { @@ -25,7 +26,7 @@ struct ReaderFeedCell: View { extension SiteIconViewModel { init(feed: ReaderFeed, size: Size = .regular) { - self.size = size + self.init(size: size) if let iconURL = feed.blavatarURL { self.imageURL = SiteIconViewModel.optimizedURL(for: iconURL.absoluteString, imageSize: size.size) } diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift index 73e89417ec12..6bd3e5d084fe 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift @@ -22,7 +22,7 @@ struct ReaderPostMenu { share, copyPostLink, viewPostInBrowser, - + post.isSeenSupported ? toggleSeen : nil ].compactMap { $0 }) } @@ -51,7 +51,7 @@ struct ReaderPostMenu { // MARK: Actions private var share: UIAction { - UIAction(Strings.share, systemImage: "square.and.arrow.up") { + UIAction(SharedStrings.Button.share, systemImage: "square.and.arrow.up") { guard let viewController else { return } ReaderShareAction().execute(with: post, anchor: anchor ?? viewController.view, vc: viewController) track(.share) @@ -71,7 +71,7 @@ struct ReaderPostMenu { private var copyPostLink: UIAction? { guard let postURL = post.permaLink.flatMap(URL.init) else { return nil } - return UIAction(Strings.copyLink, systemImage: "link") { + return UIAction(SharedStrings.Button.copyLink, systemImage: "link") { UIPasteboard.general.string = postURL.absoluteString UINotificationFeedbackGenerator().notificationOccurred(.success) track(.copyPostLink) @@ -79,7 +79,7 @@ struct ReaderPostMenu { } private var goToBlog: UIAction { - UIAction(Strings.goToBlog, systemImage: "chevron.right") { + UIAction(Strings.goToBlog, systemImage: "chevron.forward") { guard let viewController else { return } ReaderHeaderAction().execute(post: post, origin: viewController) track(.goToBlog) @@ -87,7 +87,7 @@ struct ReaderPostMenu { } private var subscribe: UIAction { - UIAction(Strings.subscribe, systemImage: "plus.circle") { + UIAction(SharedStrings.Reader.subscribe, systemImage: "plus.circle") { ReaderSubscriptionHelper().toggleSiteSubscription(forPost: post) track(.subscribe) } @@ -102,7 +102,7 @@ struct ReaderPostMenu { } private var ubsubscribe: UIAction { - UIAction(Strings.unsubscribe, systemImage: "minus.circle", attributes: [.destructive]) { + UIAction(SharedStrings.Reader.unsubscribe, systemImage: "minus.circle", attributes: [.destructive]) { ReaderSubscriptionHelper().toggleSiteSubscription(forPost: post) track(.unsubscribe) } @@ -116,6 +116,17 @@ struct ReaderPostMenu { } } + private var toggleSeen: UIAction { + UIAction(post.isSeen ? Strings.markUnread : Strings.markRead, systemImage: post.isSeen ? "circle" : "checkmark.circle") { + track(post.isSeen ? .markUnread : .markRead) + ReaderSeenAction().execute(with: post, context: context, completion: { + NotificationCenter.default.post(name: .ReaderPostSeenToggled, object: nil, userInfo: [ReaderNotificationKeys.post: post]) + }, failure: { _ in + UINotificationFeedbackGenerator().notificationOccurred(.error) + }) + } + } + // MARK: Block and Report private func makeBlockOrReportActions() -> UIMenu { @@ -195,20 +206,20 @@ private enum ReaderPostMenuAnalyticsButton: String { case blockUser = "block_user" case reportPost = "report_post" case reportUser = "report_user" + case markRead = "mark_read" + case markUnread = "mark_unread" } private enum Strings { - static let share = NSLocalizedString("reader.postContextMenu.share", value: "Share", comment: "Context menu action") static let viewInBrowser = NSLocalizedString("reader.postContextMenu.viewInBrowser", value: "View in Browser", comment: "Context menu action") - static let copyLink = NSLocalizedString("reader.postContextMenu.copyLink", value: "Copy Link", comment: "Context menu action") static let blockOrReport = NSLocalizedString("reader.postContextMenu.blockOrReportMenu", value: "Block or Report", comment: "Context menu action") static let goToBlog = NSLocalizedString("reader.postContextMenu.showBlog", value: "Go to Blog", comment: "Context menu action") - static let subscribe = NSLocalizedString("reader.postContextMenu.subscribeT", value: "Subscribe", comment: "Context menu action") - static let unsubscribe = NSLocalizedString("reader.postContextMenu.unsubscribe", value: "Unsubscribe", comment: "Context menu action") static let manageNotifications = NSLocalizedString("reader.postContextMenu.manageNotifications", value: "Manage Notifications", comment: "Context menu action") static let blogDetails = NSLocalizedString("reader.postContextMenu.blogDetails", value: "Blog Details", comment: "Context menu action (placeholder value when blog name not available – should never happen)") static let blockSite = NSLocalizedString("reader.postContextMenu.blockSite", value: "Block Site", comment: "Context menu action") static let blockUser = NSLocalizedString("reader.postContextMenu.blockUser", value: "Block User", comment: "Context menu action") static let reportPost = NSLocalizedString("reader.postContextMenu.reportPost", value: "Report Post", comment: "Context menu action") static let reportUser = NSLocalizedString("reader.postContextMenu.reportUser", value: "Report User", comment: "Context menu action") + static let markRead = NSLocalizedString("reader.postContextMenu.markRead", value: "Mark as Read", comment: "Context menu action") + static let markUnread = NSLocalizedString("reader.postContextMenu.markUnread", value: "Mark as Unread", comment: "Context menu action") } diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderVisitSiteAction.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderVisitSiteAction.swift index 0e2cb91c8c29..ddad644dfeb6 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderVisitSiteAction.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderVisitSiteAction.swift @@ -14,7 +14,7 @@ final class ReaderVisitSiteAction { configuration.authenticate(account: account) } let controller = WebViewControllerFactory.controller(configuration: configuration, source: "reader_visit_site") - let navController = LightNavigationController(rootViewController: controller) + let navController = UINavigationController(rootViewController: controller) origin.present(navController, animated: true) WPAnalytics.trackReader(.readerArticleVisited) } diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSearchSuggestionsView.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSearchSuggestionsView.swift index a7a6bb56dba5..56edb2440c93 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSearchSuggestionsView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSearchSuggestionsView.swift @@ -21,7 +21,7 @@ struct ReaderSearchSuggestionsView: View { viewModel.buttonClearSearchHistoryTapped() } label: { Text(Strings.clearHistory) - .foregroundStyle(AppColor.brand) + .foregroundStyle(AppColor.primary) } } } diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Sharing.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Sharing.swift index ced6db57a6fe..2db5ceb457f2 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Sharing.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Sharing.swift @@ -17,7 +17,7 @@ extension ReaderStreamViewController { } let button = UIBarButtonItem(title: nil, image: UIImage(systemName: "square.and.arrow.up"), target: self, action: #selector(shareButtonTapped)) button.tag = NavigationItemTag.share.rawValue - button.accessibilityLabel = NSLocalizedString("Share", comment: "Spoken accessibility label") + button.accessibilityLabel = SharedStrings.Button.share addRightBarButtonItem(button) } diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController.swift index ae6bca99e855..295854a3c0c1 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController.swift @@ -2,6 +2,7 @@ import Foundation import SVProgressHUD import WordPressShared import WordPressFlux +import AsyncImageKit import UIKit import Combine import WordPressUI @@ -64,19 +65,22 @@ import AutomatticTracks return refreshControl }() - private let loadMoreThreashold = 4 + private lazy var buttonScrollToTop = ReaderButtonScrollToTop.make { [weak self] in + self?.tableView.scrollToTop(animated: true) + } + + let titleView = ReaderNavigationCustomTitleView() + private let loadMoreThreashold = 5 private let refreshInterval = 300 private var cleanupAndRefreshAfterScrolling = false private let recentlyBlockedSitePostObjectIDs = NSMutableArray() - private let heightForFooterView = CGFloat(44) private let estimatedHeightsCache = NSCache() private var isFeed = false private var syncIsFillingGap = false private var indexPathForGapMarker: IndexPath? private var didSetupView = false private var didBumpStats = false - @Lazy private var titleView = ReaderNavigationCustomTitleView() internal let scrollViewTranslationPublisher = PassthroughSubject() private let notificationsButtonViewModel = NotificationsButtonViewModel() private var notificationsButtonCancellable: AnyCancellable? @@ -88,6 +92,8 @@ import AutomatticTracks /// Configuration of cells private let cellConfiguration = ReaderCellConfiguration() + private let prefetcher = ImagePrefetcher() + enum NavigationItemTag: Int { case notifications case share @@ -300,6 +306,7 @@ import AutomatticTracks setupTableView() setupFooterView() setupContentHandler() + setupButtonScrollToTop() observeNetworkStatus() @@ -477,6 +484,7 @@ import AutomatticTracks tableViewController.didMove(toParent: self) tableConfiguration.setup(tableView) tableView.delegate = self + tableView.prefetchDataSource = self } @objc func configureRefreshControl() { @@ -489,9 +497,14 @@ import AutomatticTracks content.initializeContent(tableView: tableView, delegate: self) } + private func setupButtonScrollToTop() { + view.addSubview(buttonScrollToTop) + buttonScrollToTop.pinEdges([.leading, .bottom], to: view.safeAreaLayoutGuide, insets: isCompact ? UIEdgeInsets(horizontal: 8, vertical: 16) : UIEdgeInsets(.all, 20)) + } + private func setupFooterView() { var frame = footerView.frame - frame.size.height = heightForFooterView + frame.size.height = 44 footerView.frame = frame tableView.tableFooterView = footerView footerView.isHidden = true @@ -882,11 +895,10 @@ import AutomatticTracks if !canSync() { let alertTitle = NSLocalizedString("Unable to Load Posts", comment: "Title of a prompt saying the app needs an internet connection before it can load posts") let alertMessage = NSLocalizedString("Please check your internet connection and try again.", comment: "Politely asks the user to check their internet connection before trying again. ") - let cancelTitle = NSLocalizedString("OK", comment: "Title of a button that dismisses a prompt") let alertController = UIAlertController(title: alertTitle, message: alertMessage, preferredStyle: .alert) - alertController.addCancelActionWithTitle(cancelTitle, handler: nil) + alertController.addCancelActionWithTitle(SharedStrings.Button.ok, handler: nil) alertController.presentFromRootViewController() return @@ -894,11 +906,10 @@ import AutomatticTracks if let syncHelper, syncHelper.isSyncing { let alertTitle = NSLocalizedString("Busy", comment: "Title of a prompt letting the user know that they must wait until the current aciton completes.") let alertMessage = NSLocalizedString("Please wait until the current fetch completes.", comment: "Asks the user to wait until the currently running fetch request completes.") - let cancelTitle = NSLocalizedString("OK", comment: "Title of a button that dismisses a prompt") let alertController = UIAlertController(title: alertTitle, message: alertMessage, preferredStyle: .alert) - alertController.addCancelActionWithTitle(cancelTitle, handler: nil) + alertController.addCancelActionWithTitle(SharedStrings.Button.ok, handler: nil) alertController.presentFromRootViewController() return @@ -1377,6 +1388,10 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { // Check to see if we need to load more. syncMoreContentIfNeeded(for: tableView, indexPathForVisibleRow: indexPath) + if traitCollection.horizontalSizeClass == .regular, #available(iOS 18, *) { + cell.selectionStyle = .none + } + guard cell.isKind(of: ReaderCrossPostCell.self) else { return } @@ -1469,6 +1484,18 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { WPAnalytics.trackReader(.readerPostCardTapped, properties: topicPropertyForStats() ?? [:]) } + if traitCollection.horizontalSizeClass == .regular, #available(iOS 18, *) { + controller.preferredTransition = .zoom { [weak self] context in + guard let self, let cell = self.tableView.cellForRow(at: indexPath) else { + return nil + } + if let cell = (cell as? ReaderPostCell) { + return cell.getViewForZoomTransition() + } + return cell.contentView + } + } + navigationController?.pushViewController(controller, animated: true) tableView.deselectRow(at: indexPath, animated: false) @@ -1494,6 +1521,28 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { } } +extension ReaderStreamViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + prefetcher.startPrefetching(for: makeImageRequests(for: indexPaths)) + } + + func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { + prefetcher.stopPrefetching(for: makeImageRequests(for: indexPaths)) + + } + + private func makeImageRequests(for indexPaths: [IndexPath]) -> [ImageRequest] { + guard let window = view.window else { return [] } + let targetSize = ReaderPostCell.preferredCoverSize(in: window, isCompact: isCompact) + return indexPaths.compactMap { + guard let imageURL = getPost(at: $0)?.featuredImageURLForDisplay() else { + return nil + } + return ImageRequest(url: imageURL, options: ImageRequestOptions(size: targetSize)) + } + } +} + // MARK: - SearchableActivity Conformance extension ReaderStreamViewController: SearchableActivityConvertable { @@ -1620,7 +1669,8 @@ extension ReaderStreamViewController: UITableViewDelegate, JPScrollViewDelegate func scrollViewDidScroll(_ scrollView: UIScrollView) { layoutEmptyStateView() processJetpackBannerVisibility(scrollView) - $titleView.value?.updateAlpha(in: scrollView) + titleView.updateAlpha(in: scrollView) + buttonScrollToTop.setButtonHidden(scrollView.contentOffset.y < view.bounds.height / 3, animated: true) } } diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/WPImageViewController+Swift.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/WPImageViewController+Swift.swift deleted file mode 100644 index 5101200f9094..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/WPImageViewController+Swift.swift +++ /dev/null @@ -1,21 +0,0 @@ -import UIKit -import WordPressMedia - -extension WPImageViewController { - @objc func loadOriginalImage(for media: Media, success: @escaping (UIImage) -> Void, failure: @escaping (Error) -> Void) { - Task { @MainActor in - do { - let image = try await MediaImageService.shared.image(for: media, size: .original) - success(image) - } catch { - failure(error) - } - } - } - - @objc func startAnimationIfNeeded(for image: UIImage, in imageView: CachedAnimatedImageView?) { - if let gif = image as? AnimatedImage, let data = gif.gifData { - imageView?.animate(withGIFData: data) - } - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/WPImageViewController.h b/WordPress/Classes/ViewRelated/Reader/Controllers/WPImageViewController.h deleted file mode 100644 index 09e4d8252ad7..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/WPImageViewController.h +++ /dev/null @@ -1,33 +0,0 @@ -#import - -@import Photos; - -@class Media; -@class AbstractPost; -@class ReaderPost; - -NS_ASSUME_NONNULL_BEGIN -@interface WPImageViewController : UIViewController - -@property (nonatomic, assign) BOOL shouldDismissWithGestures; -@property (nonatomic, weak) AbstractPost* post; -@property (nonatomic, weak) ReaderPost* readerPost; - -- (instancetype)initWithImage:(UIImage *)image; -- (instancetype)initWithURL:(NSURL *)url; -- (instancetype)initWithMedia:(Media *)media; - -- (instancetype)initWithGifData:(NSData *)data; -- (instancetype)initWithExternalMediaURL:(NSURL *)url; - -- (instancetype)initWithImage:(nullable UIImage *)image andURL:(nullable NSURL *)url; -- (instancetype)initWithImage:(nullable UIImage *)image andMedia:(nullable Media *)media; - -- (void)loadImage; -- (void)hideBars:(BOOL)hide animated:(BOOL)animated; -- (void)centerImage; - -+ (BOOL)isUrlSupported:(NSURL *)url; - -@end -NS_ASSUME_NONNULL_END diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/WPImageViewController.m b/WordPress/Classes/ViewRelated/Reader/Controllers/WPImageViewController.m deleted file mode 100644 index a6fa1e690641..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/WPImageViewController.m +++ /dev/null @@ -1,536 +0,0 @@ -#import "WPImageViewController.h" -#import "WordPress-Swift.h" -@import Gridicons; - -static CGFloat const MaximumZoomScale = 4.0; -static CGFloat const MinimumZoomScale = 0.1; - -@interface WPImageViewController () - -@property (nonatomic, strong) NSURL *url; -@property (nonatomic, strong) UIImage *image; -@property (nonatomic, strong) Media *media; -@property (nonatomic, strong) NSData *data; -@property (nonatomic) BOOL isExternal; - -@property (nonatomic, assign) BOOL isLoadingImage; -@property (nonatomic, assign) BOOL isFirstLayout; -@property (nonatomic, strong) UIScrollView *scrollView; -@property (nonatomic, strong) CachedAnimatedImageView *imageView; -@property (nonatomic, strong) ImageLoader *imageLoader; -@property (nonatomic, assign) BOOL shouldHideStatusBar; -@property (nonatomic, strong) CircularProgressView *activityIndicatorView; - -@property (nonatomic) FlingableViewHandler *flingableViewHandler; -@property (nonatomic, strong) UITapGestureRecognizer *singleTapGesture; -@property (nonatomic, strong) UITapGestureRecognizer *doubleTapGesture; - -@end - -@implementation WPImageViewController - -#pragma mark - LifeCycle Methods - -- (instancetype)initWithImage:(UIImage *)image -{ - return [self initWithImage:image andURL:nil]; -} - -- (instancetype)initWithURL:(NSURL *)url -{ - return [self initWithImage:nil andURL:url]; -} - -- (instancetype)initWithMedia:(Media *)media -{ - return [self initWithImage:nil andMedia:media]; -} - -- (instancetype)initWithGifData:(NSData *)data -{ - self = [super init]; - if (self) { - _data = data; - [self commonInit]; - } - return self; -} - -- (instancetype)initWithImage:(UIImage *)image andURL:(NSURL *)url -{ - self = [super init]; - if (self) { - _image = [image copy]; - _url = url; - [self commonInit]; - } - return self; -} - -- (instancetype)initWithImage:(UIImage *)image andMedia:(Media *)media -{ - self = [super init]; - if (self) { - _image = [image copy]; - _media = media; - [self commonInit]; - } - return self; -} - -- (instancetype)initWithExternalMediaURL:(NSURL *)url -{ - self = [super init]; - if (self) { - _image = nil; - _url = url; - _isExternal = YES; - [self commonInit]; - } - return self; -} - -- (void)commonInit -{ - _shouldDismissWithGestures = YES; - _isFirstLayout = YES; -} - -- (void)setIsLoadingImage:(BOOL)isLoadingImage -{ - _isLoadingImage = isLoadingImage; - - if (isLoadingImage) { - [self.activityIndicatorView startAnimating]; - } else { - [self.activityIndicatorView stopAnimating]; - } -} - -- (void)viewDidLoad -{ - [super viewDidLoad]; - - self.view.backgroundColor = [UIColor blackColor]; - CGRect frame = self.view.frame; - frame = CGRectMake(0.0f, 0.0f, frame.size.width, frame.size.height); - - [self setupScrollView:frame]; - [self setupImageViewWidth:frame]; - [self setupImageLoader]; - - self.doubleTapGesture = [self setupTapGestureWithNumberOfTaps:2 onView:self.imageView]; - self.singleTapGesture = [self setupTapGestureWithNumberOfTaps:1 onView:self.scrollView]; - [self.singleTapGesture requireGestureRecognizerToFail:self.doubleTapGesture]; - - [self setupFlingableView]; - [self setupActivityIndicator]; - [self layoutActivityIndicator]; - - [self setupAccessibility]; - - [self loadImage]; -} - -- (void)setupActivityIndicator -{ - self.activityIndicatorView = [[CircularProgressView alloc] initWithStyle:CircularProgressViewStyleWhite]; - AccessoryView *errorView = [[AccessoryView alloc] init]; - errorView.imageView.image = [UIImage gridiconOfType:GridiconTypeNoticeOutline]; - errorView.label.text = NSLocalizedString(@"Error", @"Generic error."); - self.activityIndicatorView.errorView = errorView; -} - -- (void)layoutActivityIndicator -{ - self.activityIndicatorView.translatesAutoresizingMaskIntoConstraints = NO; - [self.view addSubview:self.activityIndicatorView]; - NSArray *constraints = @[ - [self.activityIndicatorView.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], - [self.activityIndicatorView.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor] - ]; - - [NSLayoutConstraint activateConstraints:constraints]; -} - -- (void)setupFlingableView -{ - self.flingableViewHandler = [[FlingableViewHandler alloc] initWithTargetView:self.scrollView]; - self.flingableViewHandler.delegate = self; - self.flingableViewHandler.isActive = self.shouldDismissWithGestures; -} - -- (UITapGestureRecognizer *)setupTapGestureWithNumberOfTaps:(NSInteger)taps onView:(UIView*)view -{ - UITapGestureRecognizer *gesture = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(handleTapGesture:)]; - [gesture setNumberOfTapsRequired:taps]; - [view addGestureRecognizer:gesture]; - return gesture; -} - -- (void)setupScrollView:(CGRect)frame { - self.scrollView = [[UIScrollView alloc] initWithFrame:frame]; - self.scrollView.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth; - self.scrollView.maximumZoomScale = MaximumZoomScale; - self.scrollView.minimumZoomScale = MinimumZoomScale; - self.scrollView.scrollsToTop = NO; - self.scrollView.delegate = self; - [self.view addSubview:self.scrollView]; -} - -- (void)setupImageViewWidth:(CGRect)frame -{ - self.imageView = [[CachedAnimatedImageView alloc] initWithFrame:frame]; - self.imageView.gifStrategy = GIFStrategyLargeGIFs; - self.imageView.contentMode = UIViewContentModeScaleAspectFit; - self.imageView.shouldShowLoadingIndicator = NO; - self.imageView.userInteractionEnabled = YES; - [self.scrollView addSubview:self.imageView]; -} - -- (void)setupImageLoader -{ - self.imageLoader = [[ImageLoader alloc] initWithImageView:self.imageView gifStrategy:GIFStrategyLargeGIFs]; -} - -- (void)loadImage -{ - if (self.isLoadingImage) { - return; - } - - if (self.image != nil) { - [self updateImageView]; - } else if (self.url && self.isExternal) { - [self loadImageFromExternalURL]; - } else if (self.url) { - [self loadImageFromURL]; - } else if (self.media) { - [self loadImageFromMedia]; - } else if (self.data) { - [self loadImageFromGifData]; - } -} - -- (void)updateImageView -{ - self.imageView.image = self.image; - [self.imageView sizeToFit]; - self.scrollView.contentSize = self.imageView.image.size; - [self centerImage]; - -} - -- (void)loadImageFromURL -{ - self.isLoadingImage = YES; - __weak __typeof__(self) weakSelf = self; - if (self.readerPost != NULL) { - [self.imageLoader loadImageWithURL:self.url fromReaderPost:self.readerPost preferredSize:CGSizeZero placeholder:self.image success:^{ - weakSelf.isLoadingImage = NO; - weakSelf.image = weakSelf.imageView.image; - [weakSelf updateImageView]; - } error:^(NSError * _Nullable error) { - [weakSelf.activityIndicatorView showError]; - DDLogError(@"Error loading image: %@", error); - }]; - } else { - [_imageView downloadImageUsingRequest:[NSURLRequest requestWithURL:self.url] - placeholderImage:self.image - success:^(UIImage *image) { - weakSelf.image = image; - [weakSelf updateImageView]; - weakSelf.isLoadingImage = NO; - } failure:^(NSError *error) { - DDLogError(@"Error loading image: %@", error); - [weakSelf.activityIndicatorView showError]; - }]; - } -} - -- (void)loadImageFromMedia -{ - self.imageView.image = self.image; - self.isLoadingImage = YES; - [self.activityIndicatorView startAnimating]; - - __weak __typeof__(self) weakSelf = self; - [self loadOriginalImageFor:self.media success:^(UIImage * _Nonnull image) { - weakSelf.isLoadingImage = NO; - weakSelf.image = image; - [weakSelf updateImageView]; - [weakSelf startAnimationIfNeededFor:image in:weakSelf.imageView]; - } failure:^(NSError * _Nonnull error) { - [weakSelf.activityIndicatorView showError]; - DDLogError(@"Error loading image: %@", error); - }]; -} - -- (void)loadImageFromGifData -{ - self.isLoadingImage = YES; - - __weak __typeof__(self) weakSelf = self; - dispatch_async(dispatch_get_main_queue(), ^{ - self.image = [[UIImage alloc] initWithData: self.data]; - [weakSelf updateImageView]; - }); - [self.imageView setAnimatedImage:self.data success:^{ - dispatch_async(dispatch_get_main_queue(), ^{ - weakSelf.isLoadingImage = NO; - }); - }]; -} - -- (void)loadImageFromExternalURL -{ - self.isLoadingImage = YES; - - __weak __typeof__(self) weakSelf = self; - [self.imageLoader loadImageWithURL:self.url - fromPost:self.post - preferredSize:CGSizeZero - placeholder:nil - success:^{ - weakSelf.isLoadingImage = NO; - weakSelf.image = weakSelf.imageView.image; - [weakSelf updateImageView]; - } error:^(NSError * _Nullable error) { - [weakSelf.activityIndicatorView showError]; - DDLogError(@"Error loading image: %@", error); - }]; -} - -- (void)viewWillAppear:(BOOL)animated -{ - [super viewWillAppear:animated]; - - [self hideBars:YES animated:animated]; -} - -- (void)viewDidLayoutSubviews -{ - [super viewDidLayoutSubviews]; - if (self.isFirstLayout) { - [self centerImage]; - self.isFirstLayout = NO; - } -} - -- (void)viewWillDisappear:(BOOL)animated -{ - [super viewWillDisappear:animated]; - [self hideBars:NO animated:animated]; -} - -- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator -{ - [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; - [coordinator animateAlongsideTransition:^(id _Nonnull __unused context) { - [self centerImage]; - } completion:nil]; -} - -- (BOOL)prefersHomeIndicatorAutoHidden -{ - return self.shouldHideStatusBar; -} - -#pragma mark - Instance Methods - -- (void)setShouldDismissWithGestures:(BOOL)shouldDismissWithGestures -{ - _shouldDismissWithGestures = shouldDismissWithGestures; - self.flingableViewHandler.isActive = shouldDismissWithGestures; -} - -- (void)hideBars:(BOOL)hide animated:(BOOL)animated -{ - self.shouldHideStatusBar = hide; - - // Force an update of the status bar appearance and visiblity - if (animated) { - [UIView animateWithDuration:0.3 - animations:^{ - [self setNeedsStatusBarAppearanceUpdate]; - [self setNeedsUpdateOfHomeIndicatorAutoHidden]; - }]; - } else { - [self setNeedsStatusBarAppearanceUpdate]; - - [self setNeedsUpdateOfHomeIndicatorAutoHidden]; - } -} - -- (void)centerImage -{ - CGFloat scaleWidth = CGRectGetWidth(self.scrollView.frame) / self.imageView.image.size.width; - CGFloat scaleHeight = CGRectGetHeight(self.scrollView.frame) / self.imageView.image.size.height; - - self.scrollView.minimumZoomScale = MIN(scaleWidth, scaleHeight); - self.scrollView.zoomScale = self.scrollView.minimumZoomScale; - - [self scrollViewDidZoom:self.scrollView]; -} - -- (void)handleTapGesture:(UITapGestureRecognizer *)tapGesture -{ - if ([tapGesture isEqual:self.singleTapGesture]) { - [self handleImageTappedWith:tapGesture]; - } else if ([tapGesture isEqual:self.doubleTapGesture]) { - [self handleImageDoubleTappedWidth:tapGesture]; - } -} - -- (void)handleImageTappedWith:(UITapGestureRecognizer *)tgr -{ - if (self.shouldDismissWithGestures) { - [self dismissViewControllerAnimated:YES completion:nil]; - } -} - -- (void)handleImageDoubleTappedWidth:(UITapGestureRecognizer *)tgr -{ - if (self.scrollView.zoomScale > self.scrollView.minimumZoomScale) { - [self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:YES]; - return; - } - - CGPoint point = [tgr locationInView:self.imageView]; - CGSize size = self.scrollView.frame.size; - - CGFloat w = size.width / self.scrollView.maximumZoomScale; - CGFloat h = size.height / self.scrollView.maximumZoomScale; - CGFloat x = point.x - (w / 2.0f); - CGFloat y = point.y - (h / 2.0f); - - CGRect rect = CGRectMake(x, y, w, h); - [self.scrollView zoomToRect:rect animated:YES]; -} - -#pragma mark - UIScrollView Delegate - -- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView -{ - if (self.imageView.image) { - return self.imageView; - } - return nil; -} - -- (void)scrollViewDidZoom:(UIScrollView *)scrollView -{ - CGSize size = scrollView.frame.size; - CGRect frame = self.imageView.frame; - - if (frame.size.width < size.width) { - frame.origin.x = (size.width - frame.size.width) / 2; - } else { - frame.origin.x = 0; - } - - if (frame.size.height < size.height) { - frame.origin.y = (size.height - frame.size.height) / 2; - } else { - frame.origin.y = 0; - } - - self.imageView.frame = frame; - - [self updateFlingableViewHandlerActiveState]; -} - -- (void)updateFlingableViewHandlerActiveState -{ - if (!self.shouldDismissWithGestures) { - return; - } - BOOL isScrollViewZoomedOut = (self.scrollView.zoomScale == self.scrollView.minimumZoomScale); - - self.flingableViewHandler.isActive = isScrollViewZoomedOut; -} - -#pragma mark - Status bar management - -- (BOOL)prefersStatusBarHidden -{ - return self.shouldHideStatusBar; -} - -- (UIStatusBarStyle)preferredStatusBarStyle -{ - return UIStatusBarStyleLightContent; -} - -- (UIStatusBarAnimation)preferredStatusBarUpdateAnimation -{ - return UIStatusBarAnimationFade; -} - -#pragma mark - Static Helpers - -+ (BOOL)isUrlSupported:(NSURL *)url -{ - // Safeguard - if (!url) { - return NO; - } - - // We only support: PNG + JPG + JPEG + GIF - NSString *absoluteURL = url.absoluteString; - - NSArray *types = @[@".png", @".jpg", @".gif", @".jpeg"]; - for (NSString *type in types) { - if (NSNotFound != [[absoluteURL lowercaseString] rangeOfString:type].location) { - return YES; - } - } - - return NO; -} - -#pragma mark - FlingableViewHandlerDelegate - -- (void)flingableViewHandlerDidBeginRecognizingGesture:(FlingableViewHandler *)handler -{ - self.scrollView.multipleTouchEnabled = NO; -} - -- (void)flingableViewHandlerDidEndRecognizingGesture:(FlingableViewHandler *)handler { - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self dismissViewControllerAnimated:YES completion:nil]; - }); -} - -- (void)flingableViewHandlerWasCancelled:(FlingableViewHandler *)handler -{ - self.scrollView.multipleTouchEnabled = YES; -} - -#pragma mark - Accessibility - -- (void)setupAccessibility -{ - self.imageView.isAccessibilityElement = YES; - self.imageView.accessibilityTraits = UIAccessibilityTraitImage; - - if (self.media != nil && self.media.title != nil) { - self.imageView.accessibilityLabel = [NSString stringWithFormat:NSLocalizedString(@"Fullscreen view of image %@. Double tap to dismiss", @"Accessibility label for when image is shown to user in full screen, with instructions on how to dismiss the screen. Placeholder is the title of the image"), self.media.title]; - } - else { - self.imageView.accessibilityLabel = NSLocalizedString(@"Fullscreen view of image. Double tap to dismiss", @"Accessibility label for when image is shown to user in full screen, with instructions on how to dismiss the screen"); - } - -} - -- (BOOL)accessibilityPerformEscape -{ - // Dismiss when self receives the VoiceOver escape gesture (Z). This does not seem to happen - // automatically if self is presented modally by itself (i.e. not inside a - // UINavigationController). - [self dismissViewControllerAnimated:YES completion:nil]; - return YES; -} - -@end diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift index 4aa343b2e725..4ea86b982473 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift @@ -1,5 +1,6 @@ import Foundation import WordPressShared +import AsyncImageKit import Combine class ReaderDetailCoordinator { @@ -283,12 +284,10 @@ class ReaderDetailCoordinator { func presentImage(_ url: URL) { WPAnalytics.trackReader(.readerArticleImageTapped) - let imageViewController = WPImageViewController(url: url) - imageViewController.readerPost = post - imageViewController.modalTransitionStyle = .crossDissolve - imageViewController.modalPresentationStyle = .fullScreen - - viewController?.present(imageViewController, animated: true) + let host = post.map(MediaHost.init) + let lightboxVC = LightboxViewController(sourceURL: url, host: host) + lightboxVC.configureZoomTransition() + viewController?.present(lightboxVC, animated: true) } /// Open the postURL in a separated view controller @@ -494,11 +493,12 @@ class ReaderDetailCoordinator { guard let post, let imageURL = post.featuredImage.flatMap(URL.init) else { return } - let controller = WPImageViewController(url: imageURL) - controller.readerPost = post - controller.modalTransitionStyle = .crossDissolve - controller.modalPresentationStyle = .fullScreen - viewController?.present(controller, animated: true) + let lightboxVC = LightboxViewController(sourceURL: imageURL, host: MediaHost(post)) + MainActor.assumeIsolated { + lightboxVC.thumbnail = sender.image + } + lightboxVC.configureZoomTransition(sourceView: sender) + viewController?.present(lightboxVC, animated: true) } private func followSite(completion: @escaping () -> Void) { @@ -573,7 +573,7 @@ class ReaderDetailCoordinator { configuration.authenticateWithDefaultAccount() configuration.addsWPComReferrer = true let controller = WebViewControllerFactory.controller(configuration: configuration, source: "reader_detail") - let navController = LightNavigationController(rootViewController: controller) + let navController = UINavigationController(rootViewController: controller) viewController?.present(navController, animated: true) } diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift index 8ed9af3308fd..bf431ce55fde 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift @@ -67,7 +67,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { private let activityIndicator = UIActivityIndicatorView(style: .medium) /// The actual header - private let featuredImage: ReaderDetailFeaturedImageView = .loadFromNib() + private let featuredImageView = ReaderDetailFeaturedImageView() /// The actual header private lazy var header: ReaderDetailHeaderHostingView = { @@ -202,7 +202,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { return } - featuredImage.viewWillDisappear() + featuredImageView.viewWillDisappear() toolbar.viewWillDisappear() } @@ -210,7 +210,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { super.viewWillTransition(to: size, with: coordinator) coordinator.animate(alongsideTransition: { _ in - self.featuredImage.deviceDidRotate() + self.featuredImageView.deviceDidRotate() }) } @@ -222,7 +222,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { func render(_ post: ReaderPost) { configureDiscoverAttribution(post) - featuredImage.configure(for: post, with: self) + featuredImageView.configure(for: post, with: self) toolbar.configure(for: post, in: self) header.configure(for: post) fetchLikes() @@ -245,12 +245,12 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { self?.webView.loadHTMLString(post.contentForDisplay()) } - guard !featuredImage.isLoaded else { + guard !featuredImageView.isLoaded else { return } // Load the image - featuredImage.load { [weak self] in + featuredImageView.load { [weak self] in self?.hideLoading() } @@ -301,7 +301,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { } func hideLoading() { - guard !featuredImage.isLoading, !isLoadingWebView else { + guard !featuredImageView.isLoading, !isLoadingWebView else { return } @@ -448,7 +448,7 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { } // Featured image view - featuredImage.displaySetting = displaySetting + featuredImageView.displaySetting = displaySetting // Update Reader Post web view if let contentForDisplay = post?.contentForDisplay() { @@ -507,18 +507,18 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { private func setupFeaturedImage() { configureFeaturedImage() - featuredImage.configure( + featuredImageView.configure( scrollView: scrollView, navigationBar: navigationController?.navigationBar, navigationItem: navigationItem ) - guard !featuredImage.isLoaded else { + guard !featuredImageView.isLoaded else { return } // Load the image - featuredImage.load { [weak self] in + featuredImageView.load { [weak self] in guard let self else { return } @@ -527,24 +527,24 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { } private func configureFeaturedImage() { - guard featuredImage.superview == nil else { + guard featuredImageView.superview == nil else { return } if ReaderDisplaySetting.customizationEnabled { - featuredImage.displaySetting = displaySetting + featuredImageView.displaySetting = displaySetting } - featuredImage.useCompatibilityMode = useCompatibilityMode + featuredImageView.useCompatibilityMode = useCompatibilityMode - featuredImage.delegate = coordinator + featuredImageView.delegate = coordinator - view.insertSubview(featuredImage, belowSubview: webView) + view.insertSubview(featuredImageView, belowSubview: webView) NSLayoutConstraint.activate([ - featuredImage.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0), - featuredImage.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0), - featuredImage.topAnchor.constraint(equalTo: view.topAnchor, constant: 0) + featuredImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0), + featuredImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0), + featuredImageView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0) ]) headerContainerView.translatesAutoresizingMaskIntoConstraints = false @@ -812,8 +812,6 @@ class ReaderDetailViewController: UIViewController, ReaderDetailView { extension ReaderDetailViewController: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { - guard traitCollection.horizontalSizeClass == .compact else { return } - let currentOffset = scrollView.contentOffset.y // Using `safeAreaLayoutGuide.layoutFrame.height` because it doesn't // change when we extend the scroll view size by hiding the toolbar @@ -1068,8 +1066,8 @@ private extension ReaderDetailViewController { func backButtonItem() -> UIBarButtonItem { let config = UIImage.SymbolConfiguration(weight: .semibold) - let image = UIImage(systemName: "chevron.backward", withConfiguration: config) ?? .gridicon(.chevronLeft) - let button = barButtonItem(with: image, action: #selector(didTapBackButton(_:))) + let image = UIImage(systemName: "chevron.backward", withConfiguration: config) + let button = barButtonItem(with: image ?? UIImage(), action: #selector(didTapBackButton(_:))) button.accessibilityLabel = Strings.backButtonAccessibilityLabel return button } @@ -1133,7 +1131,7 @@ private extension ReaderDetailViewController { func shareButtonItem(enabled: Bool = true) -> UIBarButtonItem? { let button = barButtonItem(with: .gridicon(.shareiOS), action: #selector(didTapShareButton(_:))) - button.accessibilityLabel = Strings.shareButtonAccessibilityLabel + button.accessibilityLabel = SharedStrings.Button.share button.isEnabled = enabled return button @@ -1143,17 +1141,6 @@ private extension ReaderDetailViewController { let image = image.withRenderingMode(.alwaysTemplate) return UIBarButtonItem(image: image, style: .plain, target: self, action: action) } - - /// Checks if the view is visible in the viewport. - func isVisibleInScrollView(_ view: UIView) -> Bool { - guard view.superview != nil, !view.isHidden else { - return false - } - - let scrollViewFrame = CGRect(origin: scrollView.contentOffset, size: scrollView.frame.size) - let convertedViewFrame = scrollView.convert(view.bounds, from: view) - return scrollViewFrame.intersects(convertedViewFrame) - } } // MARK: - NoResultsViewControllerDelegate @@ -1186,11 +1173,7 @@ extension ReaderDetailViewController { value: "Open in Safari", comment: "Spoken accessibility label" ) - static let shareButtonAccessibilityLabel = NSLocalizedString( - "readerDetail.shareButton.accessibilityLabel", - value: "Share", - comment: "Spoken accessibility label" - ) + static let moreButtonAccessibilityLabel = NSLocalizedString( "readerDetail.moreButton.accessibilityLabel", value: "More", diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.swift index 05ea92bd8e68..4d9b516a27e4 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.swift @@ -1,5 +1,6 @@ import UIKit -import WordPressMedia +import AsyncImageKit +import WordPressUI protocol ReaderDetailFeaturedImageViewDelegate: AnyObject { func didTapFeaturedImage(_ sender: AsyncImageView) @@ -9,12 +10,12 @@ protocol UpdatableStatusBarStyle: UIViewController { func updateStatusBarStyle(to style: UIStatusBarStyle) } -class ReaderDetailFeaturedImageView: UIView, NibLoadable { +final class ReaderDetailFeaturedImageView: UIView { // MARK: - Constants struct Constants { - struct multipliers { + struct Multipliers { static let maxPortaitHeight: CGFloat = 0.70 static let maxPadPortaitHeight: CGFloat = 0.50 static let maxLandscapeHeight: CGFloat = 0.30 @@ -37,10 +38,9 @@ class ReaderDetailFeaturedImageView: UIView, NibLoadable { // MARK: - Private: IBOutlets - @IBOutlet private weak var imageView: AsyncImageView! - @IBOutlet private weak var gradientView: UIView! - @IBOutlet private weak var heightConstraint: NSLayoutConstraint! - @IBOutlet private weak var loadingView: UIView! + private let imageView = AsyncImageView() + private let gradientView = LinearGradientView() + private lazy var heightConstraint = heightAnchor.constraint(equalToConstant: 230) // MARK: - Public: Properties @@ -127,15 +127,35 @@ class ReaderDetailFeaturedImageView: UIView, NibLoadable { scrollViewObserver?.invalidate() } - override func awakeFromNib() { - super.awakeFromNib() + override init(frame: CGRect) { + super.init(frame: frame) + + translatesAutoresizingMaskIntoConstraints = false + heightConstraint.isActive = true + + gradientView.backgroundColor = UIColor.clear + gradientView.startColor = UIColor.black.withAlphaComponent(0.66) + gradientView.endColor = UIColor.clear + + addSubview(imageView) + imageView.pinEdges() + + addSubview(gradientView) + gradientView.pinEdges([.top, .horizontal]) + NSLayoutConstraint.activate([ + gradientView.heightAnchor.constraint(equalToConstant: 120).withPriority(999), + gradientView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor) // Make sure it collapses + ]) - loadingView.backgroundColor = .placeholderText isUserInteractionEnabled = false reset() } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + func viewWillDisappear() { scrollViewObserver?.invalidate() scrollViewObserver = nil @@ -192,8 +212,6 @@ class ReaderDetailFeaturedImageView: UIView, NibLoadable { return } - loadingView.isHidden = false - isLoading = true isLoaded = true @@ -223,7 +241,7 @@ class ReaderDetailFeaturedImageView: UIView, NibLoadable { completionHandler(CGSize(width: 1000, height: 1000 * ReaderPostCell.coverAspectRatio)) } - imageView.setImage(with: imageURL, host: MediaHost(with: post)) { [weak self] result in + imageView.setImage(with: ImageRequest(url: imageURL, host: MediaHost(post))) { [weak self] result in guard let self else { return } switch result { case .success: @@ -235,7 +253,6 @@ class ReaderDetailFeaturedImageView: UIView, NibLoadable { completionHandler(size) } } - self.hideLoading() case .failure: failureHandler() } @@ -281,7 +298,7 @@ class ReaderDetailFeaturedImageView: UIView, NibLoadable { return } - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(imageTapped(_:))) + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(imageTapped)) tapGesture.cancelsTouchesInView = false tapGesture.delegate = self scrollView.addGestureRecognizer(tapGesture) @@ -315,15 +332,6 @@ class ReaderDetailFeaturedImageView: UIView, NibLoadable { updateNavigationBar(in: scrollView) } - private func hideLoading() { - UIView.animate(withDuration: 0.3, animations: { - self.loadingView.alpha = 0.0 - }) { (_) in - self.loadingView.isHidden = true - self.loadingView.alpha = 1.0 - } - } - private func scrollViewDidScroll() { self.updateIfNotLoading() } @@ -396,8 +404,6 @@ class ReaderDetailFeaturedImageView: UIView, NibLoadable { resetStatusBarStyle() heightConstraint.constant = 0 isHidden = true - - loadingView.isHidden = true } private func resetStatusBarStyle() { @@ -418,10 +424,7 @@ class ReaderDetailFeaturedImageView: UIView, NibLoadable { // MARK: - Private: Calculations private func featuredImageHeight() -> CGFloat { - guard - let imageSize = self.imageSize, - let superview = self.superview - else { + guard let imageSize, let superview else { return 0 } @@ -429,7 +432,7 @@ class ReaderDetailFeaturedImageView: UIView, NibLoadable { let height = bounds.width / aspectRatio let isLandscape = UIDevice.current.orientation.isLandscape - let maxHeightMultiplier: CGFloat = isLandscape ? Constants.multipliers.maxLandscapeHeight : UIDevice.isPad() ? Constants.multipliers.maxPadPortaitHeight : Constants.multipliers.maxPortaitHeight + let maxHeightMultiplier: CGFloat = isLandscape ? Constants.Multipliers.maxLandscapeHeight : UIDevice.isPad() ? Constants.Multipliers.maxPadPortaitHeight : Constants.Multipliers.maxPortaitHeight let result = min(height, superview.bounds.height * maxHeightMultiplier) diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.xib b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.xib deleted file mode 100644 index 76877ca18fdc..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.xib +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift index dbe0f4e51d47..bfc1b4b8fc3e 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AsyncImageKit import WordPressUI protocol ReaderDetailHeaderViewDelegate: AnyObject { diff --git a/WordPress/Classes/ViewRelated/Reader/Headers/ReaderDiscoverHeaderView.swift b/WordPress/Classes/ViewRelated/Reader/Headers/ReaderDiscoverHeaderView.swift index eca49f1b5ff6..d8c5c17d7605 100644 --- a/WordPress/Classes/ViewRelated/Reader/Headers/ReaderDiscoverHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Headers/ReaderDiscoverHeaderView.swift @@ -22,6 +22,8 @@ final class ReaderDiscoverHeaderView: ReaderBaseHeaderView, UITextViewDelegate { scrollView.addSubview(channelsStackView) scrollView.showsHorizontalScrollIndicator = false scrollView.clipsToBounds = false + scrollView.decelerationRate = .fast + channelsStackView.pinEdges() scrollView.heightAnchor.constraint(equalTo: channelsStackView.heightAnchor).isActive = true @@ -65,7 +67,7 @@ final class ReaderDiscoverHeaderView: ReaderBaseHeaderView, UITextViewDelegate { private func updateScrollViewInsets() { scrollView.contentInset.left = contentView.frame.minX - (isCompact ? 0 : 10) - scrollView.contentInset.right = frame.maxX - contentView.frame.maxX + scrollView.contentInset.right = frame.maxX - contentView.frame.maxX + 10 scrollView.contentOffset = CGPoint(x: -scrollView.contentInset.left, y: 0) } @@ -213,7 +215,7 @@ enum ReaderDiscoverChannel: Hashable { case .dailyPrompts: NSLocalizedString("reader.discover.channel.dailyPrompts", value: "Daily Prompts", comment: "Header view channel (filter)") case .tag(let tag): - tag.title.localizedCapitalized + tag.formattedTitle } } diff --git a/WordPress/Classes/ViewRelated/Reader/Headers/ReaderTagStreamHeader.swift b/WordPress/Classes/ViewRelated/Reader/Headers/ReaderTagStreamHeader.swift index 139e9238b261..4abd228e57f3 100644 --- a/WordPress/Classes/ViewRelated/Reader/Headers/ReaderTagStreamHeader.swift +++ b/WordPress/Classes/ViewRelated/Reader/Headers/ReaderTagStreamHeader.swift @@ -41,7 +41,11 @@ final class ReaderTagStreamHeader: ReaderBaseHeaderView, ReaderStreamHeader { // MARK: - Configuration public func configureHeader(_ topic: ReaderAbstractTopic) { - titleLabel.text = topic.title.split(separator: "-").map { $0.capitalized }.joined(separator: " ") + if let tag = topic as? ReaderTagTopic { + titleLabel.text = tag.formattedTitle + } else { + titleLabel.text = topic.title + } followButton.isSelected = topic.following WPStyleGuide.applyTagsReaderButtonStyle(followButton) } diff --git a/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewModel.swift b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewModel.swift index d89f3c859b18..2cb097abeb7e 100644 --- a/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewModel.swift +++ b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewModel.swift @@ -198,7 +198,7 @@ extension ReaderTagsTableViewModel { let title = NSLocalizedString("Could Not Follow Topic", comment: "Title of a prompt informing the user there was a probem unsubscribing from a topic in the reader.") let message = error?.localizedDescription let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addCancelActionWithTitle(NSLocalizedString("OK", comment: "Button title. An acknowledgement of the message displayed in a prompt.")) + alert.addCancelActionWithTitle(SharedStrings.Button.ok) alert.presentFromRootViewController() }, source: "manage") } @@ -215,7 +215,7 @@ extension ReaderTagsTableViewModel { let title = NSLocalizedString("Could Not Remove Topic", comment: "Title of a prompt informing the user there was a probem unsubscribing from a topic in the reader.") let message = error?.localizedDescription let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addCancelActionWithTitle(NSLocalizedString("OK", comment: "Button title. An acknowledgement of the message displayed in a prompt.")) + alert.addCancelActionWithTitle(SharedStrings.Button.ok) alert.presentFromRootViewController() } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderButtonScrollToTop.swift b/WordPress/Classes/ViewRelated/Reader/ReaderButtonScrollToTop.swift new file mode 100644 index 000000000000..3ad73d91e0b7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderButtonScrollToTop.swift @@ -0,0 +1,34 @@ +import UIKit + +final class ReaderButtonScrollToTop: UIButton { + private var isButtonHidden = false + + static func make(closure: @escaping () -> Void) -> ReaderButtonScrollToTop { + var configuration = UIButton.Configuration.bordered() + configuration.image = UIImage(systemName: "arrow.up")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: 12, weight: .regular)) + configuration.cornerStyle = .capsule + configuration.baseBackgroundColor = .secondarySystemBackground + configuration.baseForegroundColor = .label + configuration.contentInsets = .init(top: 10, leading: 10, bottom: 10, trailing: 10) + + return ReaderButtonScrollToTop(configuration: configuration, primaryAction: .init { _ in + closure() + WPAnalytics.track(.readerButtonScrollToTopTapped) + }) + } + + func setButtonHidden(_ isHidden: Bool, animated: Bool) { + guard isButtonHidden != isHidden else { return } + isButtonHidden = isHidden + + UIView.animate(withDuration: animated ? 0.33 : 0.0) { + self.alpha = isHidden ? 0 : 1 + self.isUserInteractionEnabled = !isHidden + } + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + bounds.insetBy(dx: -8, dy: -10).contains(point) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderNavigationCustomTitleView.swift b/WordPress/Classes/ViewRelated/Reader/ReaderNavigationCustomTitleView.swift index a940e26ff144..bcabed72ade3 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderNavigationCustomTitleView.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderNavigationCustomTitleView.swift @@ -4,31 +4,36 @@ import WordPressUI /// A custom replacement for a navigation bar title view. final class ReaderNavigationCustomTitleView: UIView { let textLabel = UILabel() + let detailsLabel = UILabel() + private lazy var stackView = UIStackView(axis: .vertical, alignment: .center, [textLabel, detailsLabel]) override init(frame: CGRect) { super.init(frame: frame) textLabel.font = WPStyleGuide.navigationBarStandardFont - textLabel.alpha = 0 - // The label has to be a subview of the title view because - // navigation bar doesn't seem to allow you to change the alpha - // of `navigationItem.titleView` itself. - addSubview(textLabel) - textLabel.pinEdges() + detailsLabel.font = .preferredFont(forTextStyle: .footnote) + detailsLabel.textColor = .secondaryLabel + detailsLabel.isHidden = true + + addSubview(stackView) + stackView.pinEdges() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + // The label has to be a subview of the title view because + // navigation bar doesn't seem to allow you to change the alpha + // of `navigationItem.titleView` itself. func updateAlpha(in scrollView: UIScrollView) { let offsetY = scrollView.contentOffset.y if offsetY < 16 { - textLabel.alpha = 0 + stackView.alpha = 0 } else { let alpha = (offsetY - 16) / 24 - textLabel.alpha = max(0, min(1, alpha)) + stackView.alpha = max(0, min(1, alpha)) } } } diff --git a/WordPress/Classes/ViewRelated/Reader/Sidebar/ReaderSidebarSubscriptionsSection.swift b/WordPress/Classes/ViewRelated/Reader/Sidebar/ReaderSidebarSubscriptionsSection.swift index 40a6100cd2ac..b435a270b9da 100644 --- a/WordPress/Classes/ViewRelated/Reader/Sidebar/ReaderSidebarSubscriptionsSection.swift +++ b/WordPress/Classes/ViewRelated/Reader/Sidebar/ReaderSidebarSubscriptionsSection.swift @@ -30,6 +30,7 @@ struct ReaderSidebarSubscriptionsSection: View { struct ReaderSidebarSubscriptionCell: View { @ObservedObject var site: ReaderSiteTopic @Environment(\.editMode) var editMode + @State private var isShowingSettings = false var body: some View { HStack { @@ -40,28 +41,92 @@ struct ReaderSidebarSubscriptionCell: View { } if editMode?.wrappedValue.isEditing == true { Spacer() - Button { - if !site.showInMenu { - WPAnalytics.track(.readerAddSiteToFavoritesTapped) - } - - let siteObjectID = TaggedManagedObjectID(site) - ContextManager.shared.performAndSave({ managedObjectContext in - let site = try managedObjectContext.existingObject(with: siteObjectID) - site.showInMenu.toggle() - }, completion: nil, on: DispatchQueue.main) - } label: { - Image(systemName: site.showInMenu ? "star.fill" : "star") - .foregroundStyle(site.showInMenu ? .pink : .secondary) - }.buttonStyle(.plain) + ReaderSiteToggleFavoriteButton(site: site, source: "edit_mode") + .labelStyle(.iconOnly) } } .lineLimit(1) .tag(ReaderSidebarItem.subscription(TaggedManagedObjectID(site))) + .swipeActions(edge: .leading) { + if let siteURL = URL(string: site.siteURL) { + ShareLink(item: siteURL).tint(.blue) + } + } .swipeActions(edge: .trailing) { Button(SharedStrings.Reader.unfollow, role: .destructive) { ReaderSubscriptionHelper().unfollow(site) }.tint(.red) } + .contextMenu(menuItems: { + ReaderSubscriptionContextMenu(site: site, isShowingSettings: $isShowingSettings) + }, preview: { + ReaderTopicPreviewView(topic: site) + }) + .sheet(isPresented: $isShowingSettings) { + ReaderSubscriptionNotificationSettingsView(siteID: site.siteID.intValue) + .presentationDetents([.medium, .large]) + .edgesIgnoringSafeArea(.bottom) + } + } +} + +struct ReaderSubscriptionContextMenu: View { + let site: ReaderSiteTopic + + @Binding var isShowingSettings: Bool + + var body: some View { + if let siteURL = URL(string: site.siteURL) { + ShareLink(item: siteURL) + Button(SharedStrings.Button.copyLink, systemImage: "doc.on.doc") { + UIPasteboard.general.string = siteURL.absoluteString + } + } + if site.following { + ReaderSiteToggleFavoriteButton(site: site, source: "context_menu") + Button(SharedStrings.Reader.notificationSettings, systemImage: "bell") { + isShowingSettings = true + } + Button(SharedStrings.Reader.unsubscribe, systemImage: "minus.circle", role: .destructive) { + ReaderSubscriptionHelper().unfollow(site) + } + } else { + Button(SharedStrings.Reader.subscribe, systemImage: "plus.circle") { + ReaderSubscriptionHelper().toggleFollowingForSite(site) + } + } + } +} + +struct ReaderTopicPreviewView: UIViewControllerRepresentable { + let topic: ReaderAbstractTopic + + func makeUIViewController(context: Context) -> ReaderStreamViewController { + ReaderStreamViewController.controllerWithTopic(topic) + } + + func updateUIViewController(_ vc: ReaderStreamViewController, context: Context) { + // Do nothing + } +} + +struct ReaderSiteToggleFavoriteButton: View { + let site: ReaderSiteTopic + let source: String + + var body: some View { + Button { + if !site.showInMenu { + WPAnalytics.track(.readerAddSiteToFavoritesTapped, properties: ["via": source]) + } + let siteObjectID = TaggedManagedObjectID(site) + ContextManager.shared.performAndSave({ managedObjectContext in + let site = try managedObjectContext.existingObject(with: siteObjectID) + site.showInMenu.toggle() + }, completion: nil, on: DispatchQueue.main) + } label: { + Label(site.showInMenu ? SharedStrings.Reader.removeFromFavorites : SharedStrings.Reader.addToFavorites, systemImage: site.showInMenu ? "star.fill" : "star") + .foregroundStyle(site.showInMenu ? .pink : .secondary) + }.buttonStyle(.plain) } } diff --git a/WordPress/Classes/ViewRelated/Reader/Sidebar/ReaderSidebarTagsSection.swift b/WordPress/Classes/ViewRelated/Reader/Sidebar/ReaderSidebarTagsSection.swift index 860942c50ec6..5383da62daae 100644 --- a/WordPress/Classes/ViewRelated/Reader/Sidebar/ReaderSidebarTagsSection.swift +++ b/WordPress/Classes/ViewRelated/Reader/Sidebar/ReaderSidebarTagsSection.swift @@ -37,14 +37,14 @@ struct ReaderSidebarTagsSection: View { } label: { Label(Strings.addTag, systemImage: "plus.circle") } - .listItemTint(AppColor.brand) + .listItemTint(AppColor.primary) Button { viewModel.navigate(.discoverTags) } label: { Label(Strings.discoverTags, systemImage: "sparkle.magnifyingglass") } - .listItemTint(AppColor.brand) + .listItemTint(AppColor.primary) } func delete(at offsets: IndexSet) { diff --git a/WordPress/Classes/ViewRelated/Reader/Sidebar/ReaderSidebarViewController.swift b/WordPress/Classes/ViewRelated/Reader/Sidebar/ReaderSidebarViewController.swift index 8a0cfd2d3714..482d07fdad15 100644 --- a/WordPress/Classes/ViewRelated/Reader/Sidebar/ReaderSidebarViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Sidebar/ReaderSidebarViewController.swift @@ -74,7 +74,6 @@ private struct ReaderSidebarView: View { @State private var searchText = "" - @Environment(\.layoutDirection) var layoutDirection @Environment(\.editMode) var editMode var isEditing: Bool { editMode?.wrappedValue.isEditing == true } @@ -143,7 +142,7 @@ private struct ReaderSidebarView: View { makeSection(Strings.subscriptions, isExpanded: $isSectionSubscriptionsExpanded) { Label(Strings.subscriptions, systemImage: "checkmark.rectangle.stack") .tag(ReaderSidebarItem.allSubscriptions) - .listItemTint(AppColor.brand) + .listItemTint(AppColor.primary) .withDisabledSelection(isEditing) ReaderSidebarSubscriptionsSection(viewModel: viewModel) @@ -159,11 +158,16 @@ private struct ReaderSidebarView: View { private func makePrimaryNavigationItem(_ title: String, systemImage: String) -> some View { HStack { - Label(title, systemImage: systemImage) - .lineLimit(1) + Label { + Text(title) + .font(.headline).fontWeight(.medium) + } icon: { + Image(systemName: systemImage) + } + .lineLimit(1) if viewModel.isCompact { Spacer() - Image(systemName: layoutDirection == .rightToLeft ? "chevron.left" : "chevron.right") + Image(systemName: "chevron.forward") .font(.system(size: 14).weight(.medium)) .foregroundStyle(.secondary.opacity(0.8)) } @@ -195,8 +199,6 @@ private struct ReaderSidebarSection: View { var isCompact: Bool @ViewBuilder var content: () -> Content - @Environment(\.layoutDirection) var layoutDirection - var body: some View { if isCompact { Button { @@ -207,9 +209,9 @@ private struct ReaderSidebarSection: View { .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) Spacer() - Image(systemName: isExpanded ? "chevron.down" : (layoutDirection == .rightToLeft ? "chevron.left" : "chevron.right")) + Image(systemName: isExpanded ? "chevron.down" : "chevron.forward") .font(.system(size: 14).weight(.semibold)) - .foregroundStyle(AppColor.brand) + .foregroundStyle(AppColor.primary) .frame(width: 14) } .contentShape(Rectangle()) diff --git a/WordPress/Classes/ViewRelated/Reader/Style/WPStyleGuide+ReaderComments.swift b/WordPress/Classes/ViewRelated/Reader/Style/WPStyleGuide+ReaderComments.swift index 23934bf4db7f..19c8fb98b262 100644 --- a/WordPress/Classes/ViewRelated/Reader/Style/WPStyleGuide+ReaderComments.swift +++ b/WordPress/Classes/ViewRelated/Reader/Style/WPStyleGuide+ReaderComments.swift @@ -29,6 +29,6 @@ extension WPStyleGuide { static let buttonTitleLabelFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) static let buttonBorderColor = UIColor.systemGray3 static let switchOnTintColor = UIColor.systemGreen - static let switchInProgressTintColor = UIAppColor.brand + static let switchInProgressTintColor = UIAppColor.primary } } diff --git a/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionCell.swift b/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionCell.swift index 7820f3d9a327..c976a7d1298e 100644 --- a/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionCell.swift @@ -1,4 +1,5 @@ import SwiftUI +import WordPressUI struct ReaderSubscriptionCell: View { let site: ReaderSiteTopic @@ -37,11 +38,19 @@ struct ReaderSubscriptionCell: View { Spacer() - if let status = ReaderSubscriptionNotificationsStatus(site: site) { - makeButtonNotificationSettings(with: status) + HStack(spacing: 0) { + if let status = ReaderSubscriptionNotificationsStatus(site: site) { + makeButtonNotificationSettings(with: status) + } + buttonMore } - buttonMore + .padding(.trailing, -16) } + .contextMenu(menuItems: { + ReaderSubscriptionContextMenu(site: site, isShowingSettings: $isShowingSettings) + }, preview: { + ReaderTopicPreviewView(topic: site) + }) } private func makeButtonNotificationSettings(with status: ReaderSubscriptionNotificationsStatus) -> some View { @@ -52,10 +61,10 @@ struct ReaderSubscriptionCell: View { switch status { case .all: Image(systemName: "bell.and.waves.left.and.right") - .foregroundStyle(AppColor.brand) + .foregroundStyle(AppColor.primary) case .personalized: Image(systemName: "bell") - .foregroundStyle(AppColor.brand) + .foregroundStyle(AppColor.primary) case .none: Image(systemName: "bell.slash") .foregroundStyle(.secondary) @@ -64,36 +73,24 @@ struct ReaderSubscriptionCell: View { } .font(.subheadline) .frame(width: 34, alignment: .center) - .padding(.trailing, 6) + .contentShape(Rectangle()) } .buttonStyle(.plain) - .popover(isPresented: $isShowingSettings) { settings } - } - - @ViewBuilder - private var settings: some View { - if horizontalSizeClass == .compact { - ReaderSubscriptionNotificationSettingsView(siteID: site.siteID.intValue, isCompact: true) - .presentationDetents([.medium, .large]) - .edgesIgnoringSafeArea(.all) - } else { + .sheet(isPresented: $isShowingSettings) { ReaderSubscriptionNotificationSettingsView(siteID: site.siteID.intValue) + .presentationDetents([.medium, .large]) + .edgesIgnoringSafeArea(.bottom) } } private var buttonMore: some View { Menu { - if let siteURL = URL(string: site.siteURL) { - ShareLink(item: siteURL) - } - Button(role: .destructive) { - onDelete(site) - } label: { - Label(SharedStrings.Reader.unfollow, systemImage: "trash") - } + ReaderSubscriptionContextMenu(site: site, isShowingSettings: $isShowingSettings) } label: { Image(systemName: "ellipsis") .foregroundStyle(.secondary) + .frame(width: 40, height: 40) + .contentShape(Rectangle()) } .buttonStyle(.plain) } diff --git a/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionNotificationSettingsView.swift b/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionNotificationSettingsView.swift index 9242977fe0e1..350679a1d34f 100644 --- a/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionNotificationSettingsView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionNotificationSettingsView.swift @@ -3,30 +3,22 @@ import UIKit struct ReaderSubscriptionNotificationSettingsView: UIViewControllerRepresentable { let siteID: Int - var isCompact = false @Environment(\.dismiss) var dismiss func makeUIViewController(context: Context) -> UIViewController { let vc = NotificationSiteSubscriptionViewController(siteId: siteID) - if isCompact { - vc.navigationItem.rightBarButtonItem = UIBarButtonItem(title: SharedStrings.Button.done, primaryAction: .init { _ in - dismiss() - }) - // - warning: UIKit is used to prevent the modifiers from the - // containing list to affect this screen/ - return UINavigationController(rootViewController: vc) - } - return vc + vc.navigationItem.rightBarButtonItem = UIBarButtonItem(title: SharedStrings.Button.done, primaryAction: .init { _ in + dismiss() + }) + // - warning: UIKit is used to prevent the modifiers from the + // containing list to affect this screen/ + return UINavigationController(rootViewController: vc) } func updateUIViewController(_ uiViewController: UIViewController, context: Context) { // Do nothing } - - func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: UIViewController, context: Context) -> CGSize? { - isCompact ? nil : CGSize(width: 320, height: 434) - } } extension NotificationSiteSubscriptionViewController { diff --git a/WordPress/Classes/ViewRelated/Reader/Views/ReaderAvatarView.swift b/WordPress/Classes/ViewRelated/Reader/Views/ReaderAvatarView.swift index 7c76771caa4e..34c78ee41187 100644 --- a/WordPress/Classes/ViewRelated/Reader/Views/ReaderAvatarView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Views/ReaderAvatarView.swift @@ -1,4 +1,5 @@ import UIKit +import AsyncImageKit final class ReaderAvatarView: UIView { private let asyncImageView = AsyncImageView() @@ -42,7 +43,7 @@ final class ReaderAvatarView: UIView { asyncImageView.image = image } - func setImage(with imageURL: URL, size: CGSize? = nil) { + func setImage(with imageURL: URL, size: ImageSize? = nil) { asyncImageView.setImage(with: imageURL, size: size) } } diff --git a/WordPress/Classes/ViewRelated/Reader/Views/ReaderSiteIconView.swift b/WordPress/Classes/ViewRelated/Reader/Views/ReaderSiteIconView.swift index 27363ecacf74..339720cdaa06 100644 --- a/WordPress/Classes/ViewRelated/Reader/Views/ReaderSiteIconView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Views/ReaderSiteIconView.swift @@ -1,5 +1,6 @@ import SwiftUI -import WordPressMedia +import AsyncImageKit +import WordPressUI struct ReaderSiteIconView: View, Hashable { let site: ReaderSiteTopic diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/Posting Activity/PostingActivityCell.xib b/WordPress/Classes/ViewRelated/Stats/Insights/Posting Activity/PostingActivityCell.xib index f8c777f95afc..bb00100f02b4 100644 --- a/WordPress/Classes/ViewRelated/Stats/Insights/Posting Activity/PostingActivityCell.xib +++ b/WordPress/Classes/ViewRelated/Stats/Insights/Posting Activity/PostingActivityCell.xib @@ -1,10 +1,11 @@ - + - + + @@ -42,16 +43,17 @@ - - + + + - - + + @@ -92,29 +94,27 @@ + + + + + - - - - - - - @@ -129,6 +129,9 @@ - + + + + diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/StatsBaseCell.swift b/WordPress/Classes/ViewRelated/Stats/Insights/StatsBaseCell.swift index 0d8bc1dbc408..d4511ad1e02c 100644 --- a/WordPress/Classes/ViewRelated/Stats/Insights/StatsBaseCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Insights/StatsBaseCell.swift @@ -1,5 +1,4 @@ import UIKit -import DesignSystem class StatsBaseCell: UITableViewCell { @@ -15,23 +14,17 @@ class StatsBaseCell: UITableViewCell { }() private lazy var showDetailsButton: UIButton = { - let button = UIButton() + var configuration = UIButton.Configuration.plain() + configuration.image = UIImage(systemName: "chevron.forward") + configuration.buttonSize = .small + configuration.imagePadding = 4 + configuration.baseForegroundColor = .secondaryLabel + configuration.imagePlacement = .trailing + configuration.titleLineBreakMode = .byTruncatingTail + + let button = UIButton(configuration: configuration) button.translatesAutoresizingMaskIntoConstraints = true button.addTarget(self, action: #selector(detailsButtonTapped), for: .touchUpInside) - button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .callout) - button.titleLabel?.adjustsFontSizeToFitWidth = true - button.tintColor = .secondaryLabel - button.setTitleColor(.secondaryLabel, for: .normal) - button.setImage(UIImage.gridicon(.chevronRight).withTintColor(UIColor(color: WPStyleGuide.greyLighten20())), for: .normal) - - if UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft { - button.semanticContentAttribute = .forceLeftToRight - button.titleEdgeInsets = Metrics.rtlButtonTitleInsets - } else { - button.semanticContentAttribute = .forceRightToLeft - button.titleEdgeInsets = Metrics.buttonTitleInsets - } - button.accessibilityHint = LocalizedText.buttonAccessibilityHint return button }() @@ -39,7 +32,7 @@ class StatsBaseCell: UITableViewCell { private let stackView: UIStackView = { let stackView = UIStackView() stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.spacing = Metrics.stackSpacing + stackView.spacing = 8 stackView.axis = .horizontal stackView.alignment = .fill stackView.distribution = .equalSpacing @@ -79,7 +72,7 @@ class StatsBaseCell: UITableViewCell { NSLayoutConstraint.activate([ stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Metrics.padding), - stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Metrics.padding), + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 0), stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Metrics.padding) ]) @@ -113,11 +106,11 @@ class StatsBaseCell: UITableViewCell { switch statSection { case .insightsViewsVisitors: - showDetailsButton.setTitle(LocalizedText.buttonTitleThisWeek, for: .normal) + showDetailsButton.configuration?.title = LocalizedText.buttonTitleThisWeek case .insightsFollowerTotals, .insightsCommentsTotals, .insightsLikesTotals: - showDetailsButton.setTitle(LocalizedText.buttonTitleViewMore, for: .normal) + showDetailsButton.configuration?.title = LocalizedText.buttonTitleViewMore default: - showDetailsButton.setTitle("", for: .normal) + showDetailsButton.configuration?.title = nil } headingWidthConstraint?.isActive = true @@ -179,11 +172,8 @@ class StatsBaseCell: UITableViewCell { } enum Metrics { - static let padding: CGFloat = .DS.Padding.double - static let bottomSpacing: CGFloat = .DS.Padding.split - static let stackSpacing: CGFloat = .DS.Padding.single - static let buttonTitleInsets = UIEdgeInsets(top: 0, left: -.DS.Padding.single, bottom: 0, right: .DS.Padding.single) - static let rtlButtonTitleInsets = UIEdgeInsets(top: 0, left: .DS.Padding.single, bottom: 0, right: -.DS.Padding.single) + static let padding: CGFloat = 16 + static let bottomSpacing: CGFloat = 12 } private enum LocalizedText { diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/StatsLatestPostSummaryInsightsCell.swift b/WordPress/Classes/ViewRelated/Stats/Insights/StatsLatestPostSummaryInsightsCell.swift index dfff7c03f493..1d60c04610df 100644 --- a/WordPress/Classes/ViewRelated/Stats/Insights/StatsLatestPostSummaryInsightsCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Insights/StatsLatestPostSummaryInsightsCell.swift @@ -1,7 +1,7 @@ import UIKit import Gridicons import DesignSystem -import WordPressMedia +import AsyncImageKit protocol LatestPostSummaryConfigurable { func configure(withInsightData lastPostInsight: StatsLastPostInsight?, andDelegate delegate: SiteStatsInsightsDelegate?) @@ -231,12 +231,9 @@ class StatsLatestPostSummaryInsightsCell: StatsBaseCell, LatestPostSummaryConfig let blog = try? Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) { postImageView.isHidden = false - let host = MediaHost(with: blog, failure: { error in - DDLogError("Failed to create media host: \(error.localizedDescription)") - }) + let host = MediaHost(blog) let targetSize = CGSize(width: Metrics.thumbnailSize, height: Metrics.thumbnailSize) - .scaled(by: traitCollection.displayScale) - postImageView.setImage(with: url, host: host, size: targetSize) + postImageView.setImage(with: url, host: host, size: ImageSize(scaling: targetSize, in: self)) } else { postImageView.isHidden = true } diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/Two Column Stats/TwoColumnCell.xib b/WordPress/Classes/ViewRelated/Stats/Insights/Two Column Stats/TwoColumnCell.xib index 0dc0f6895b1e..0a694e3ff1b8 100644 --- a/WordPress/Classes/ViewRelated/Stats/Insights/Two Column Stats/TwoColumnCell.xib +++ b/WordPress/Classes/ViewRelated/Stats/Insights/Two Column Stats/TwoColumnCell.xib @@ -1,10 +1,11 @@ - + - + + @@ -27,16 +28,17 @@ - - + + + - - + +