diff --git a/Package.swift b/Package.swift index b758da9c..32341806 100644 --- a/Package.swift +++ b/Package.swift @@ -105,6 +105,9 @@ let package = Package( "PovioKitSwiftUI", "PovioKitUtilities", "PovioKitAsync", + ], + resources: [ + .process("Resources/") ] ), ], diff --git a/Sources/Core/Extensions/Foundation/DecodableDictionary+PovioKit.swift b/Sources/Core/Extensions/Foundation/DecodableDictionary+PovioKit.swift index ea68058c..9292c086 100644 --- a/Sources/Core/Extensions/Foundation/DecodableDictionary+PovioKit.swift +++ b/Sources/Core/Extensions/Foundation/DecodableDictionary+PovioKit.swift @@ -89,7 +89,7 @@ public extension UnkeyedDecodingContainer { // MARK: - Private Model private struct AnyCodingKey: CodingKey { let stringValue: String - private (set) var intValue: Int? + private(set) var intValue: Int? init?(stringValue: String) { self.stringValue = stringValue } init?(intValue: Int) { diff --git a/Sources/UI/SwiftUI/Extensions/View+PovioKit.swift b/Sources/Core/Extensions/SwiftUI/View+PovioKit.swift similarity index 75% rename from Sources/UI/SwiftUI/Extensions/View+PovioKit.swift rename to Sources/Core/Extensions/SwiftUI/View+PovioKit.swift index 8a6ec67b..9ab8beb9 100644 --- a/Sources/UI/SwiftUI/Extensions/View+PovioKit.swift +++ b/Sources/Core/Extensions/SwiftUI/View+PovioKit.swift @@ -14,6 +14,11 @@ public extension View { frame(width: size, height: size, alignment: alignment) } + /// Returns square frame for given CGSize. + func frame(size: CGSize, alignment: Alignment = .center) -> some View { + frame(width: size.width, height: size.height, alignment: alignment) + } + /// Hides view using opacity. func hidden(_ hidden: Bool) -> some View { opacity(hidden ? 0 : 1) diff --git a/Sources/Core/Extensions/UIKit/UIImage+Kingfisher.swift b/Sources/Core/Extensions/UIKit/UIImage+Kingfisher.swift new file mode 100644 index 00000000..0d976071 --- /dev/null +++ b/Sources/Core/Extensions/UIKit/UIImage+Kingfisher.swift @@ -0,0 +1,114 @@ +// +// File.swift +// PovioKit +// +// Created by Borut Tomazin on 26. 9. 24. +// + +#if canImport(Kingfisher) +import UIKit +import Kingfisher + +public extension UIImage { + struct PrefetchResult { + let skipped: Int + let failed: Int + let completed: Int + } + + /// Downloads an image from the given URL asynchronously. + /// + /// This function uses the Kingfisher library to download an image from the specified URL. + /// It performs the download operation asynchronously and returns the downloaded UIImage. + /// If the download operation fails, the function throws an error. + /// + /// - Parameters: + /// - url: The URL from which to download the image. + /// - Returns: The downloaded UIImage. + /// + /// - Example: + /// ```swift + /// do { + /// let url = URL(string: "https://example.com/image.jpg")! + /// let downloadedImage = try await UIImage.download(from: url) + /// imageView.image = downloadedImage + /// } catch { + /// Logger.error("Failed to download image: \(error)") + /// } + /// ``` + /// + /// - Note: This function should be called from an asynchronous context using `await`. + /// - Throws: An error if the download operation fails. + static func download(from url: URL) async throws -> UIImage { + try await withCheckedThrowingContinuation { continuation in + KingfisherManager.shared.retrieveImage(with: url, options: nil, progressBlock: nil, downloadTaskUpdated: nil) { + switch $0 { + case .success(let result): + continuation.resume(returning: result.image) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + /// Prefetches images from the given URLs asynchronously. + /// + /// This function uses the Kingfisher library to prefetch images from the specified URLs. + /// It performs the prefetch operation asynchronously and returns a `PrefetchResult` containing + /// the counts of skipped, failed, and completed prefetch operations. + /// + /// It is usefull when we need to have images ready before we present the UI. + /// + /// - Parameters: + /// - urls: An array of URLs from which to prefetch images. + /// - Returns: A `PrefetchResult` containing the counts of skipped, failed, and completed prefetch operations. + /// + /// - Example: + /// ```swift + /// let urls = [ + /// URL(string: "https://example.com/image1.jpg")!, + /// URL(string: "https://example.com/image2.jpg")! + /// ] + /// let result = await UIImage.prefetch(urls: urls) + /// Logger.info("Skipped: \(result.skipped), Failed: \(result.failed), Completed: \(result.completed)") + /// ``` + /// + /// - Note: This function should be called from an asynchronous context using `await`. + @discardableResult + static func prefetch(from urls: [URL]) async -> PrefetchResult { + await withCheckedContinuation { continuation in + let prefetcher = ImagePrefetcher(urls: urls, options: nil) { skipped, failed, completed in + let result = PrefetchResult( + skipped: skipped.count, + failed: failed.count, + completed: completed.count + ) + continuation.resume(with: .success(result)) + } + prefetcher.start() + } + } + + /// Clears the image cache. + /// + /// This function clears the image cache using the specified ImageCache instance. + /// If no cache instance is provided, it defaults to using the shared cache of the KingfisherManager. + /// + /// - Parameters: + /// - cache: The ImageCache instance to be cleared. Defaults to `KingfisherManager.shared.cache`. + /// + /// - Example: + /// ```swift + /// // Clear the default shared cache + /// UIImage.clearCache() + /// + /// // Clear a specific cache instance + /// let customCache = ImageCache(name: "customCache") + /// UIImage.clearCache(customCache) + /// ``` + public static func clearCache(_ cache: ImageCache = KingfisherManager.shared.cache) { + cache.clearCache() + } +} +#endif diff --git a/Sources/Core/Extensions/UIKit/UIImage+PovioKit.swift b/Sources/Core/Extensions/UIKit/UIImage+PovioKit.swift index 6cbeeb90..f088ab83 100644 --- a/Sources/Core/Extensions/UIKit/UIImage+PovioKit.swift +++ b/Sources/Core/Extensions/UIKit/UIImage+PovioKit.swift @@ -11,11 +11,21 @@ import UIKit public extension UIImage { /// Initializes a symbol image on iOS 13 or image from the given `bundle` for given `name` + @available(*, deprecated, message: "This method doesn't bring any good value, therefore it will be removed in future versions.") convenience init?(systemNameOr name: String, in bundle: Bundle? = Bundle.main, compatibleWith traitCollection: UITraitCollection? = nil) { self.init(systemName: name, compatibleWith: traitCollection) } - /// Tints image with given color + /// Tints image with the given color. + /// + /// This method creates a new image by applying a color overlay to the original image. + /// The color overlay is blended using the `.sourceIn` blend mode, which means the + /// resulting image will have the shape of the original image but filled with the specified color. + /// + /// - Parameter color: The `UIColor` to use as the tint color. + /// - Returns: A new `UIImage` instance that is tinted with the specified color. + /// + /// - Note: If the tinting operation fails, the original image is returned. func tinted(with color: UIColor) -> UIImage { UIGraphicsBeginImageContextWithOptions(size, false, 0) let context = UIGraphicsGetCurrentContext() @@ -32,7 +42,16 @@ public extension UIImage { return tintedImage ?? self } - /// Generates new *UIImage* tinted with given color and size + /// Creates new image tinted with the given color and size. + /// + /// This method generates a new image of the given size, completely filled with the given color. + /// + /// - Parameters: + /// - color: The `UIColor` to fill the image with. + /// - size: The `CGSize` that defines the dimensions of the new image. + /// - Returns: A new `UIImage` instance filled with the specified color and size. If the image creation fails, an empty `UIImage` is returned. + /// + /// - Note: The resulting image is not resizable and will have the exact dimensions specified by the `size` parameter. static func with(color: UIColor, size: CGSize) -> UIImage { UIGraphicsBeginImageContext(size) let path = UIBezierPath(rect: CGRect(origin: CGPoint.zero, size: size)) @@ -43,13 +62,22 @@ public extension UIImage { return image ?? UIImage() } - /// Returns existing image clipped to a circle + /// Returns existing image clipped to a circle. + /// Clips the image to a circle. + /// + /// This creates a new image by clipping the original image to a circular shape. + /// The circular clipping is achieved by applying a corner radius that is half the width of the image. + /// + /// - Returns: A new `UIImage` instance that is clipped to a circle. If the clipping operation fails, the original image is returned. + /// + /// - Note: The resulting image will have a circular shape inscribed within the original image's bounds. + /// If the original image is not square, the image will still be clipped to a circle, with the diameter + /// equal to the shorter side of the original image. var clipToCircle: UIImage { let layer = CALayer() layer.frame = .init(origin: .zero, size: size) layer.contents = cgImage layer.masksToBounds = true - layer.cornerRadius = size.width / 2 UIGraphicsBeginImageContext(size) @@ -59,6 +87,225 @@ public extension UIImage { UIGraphicsEndImageContext() return roundedImage ?? self } + + /// Saves the image to the photo library asynchronously. + /// + /// This function saves this UIImage to the user's photo library. It performs + /// the operation asynchronously and throws an error if the save operation fails. + /// + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// try await image.saveImageToPhotoLibrary() + /// print("Image saved successfully.") + /// } catch { + /// print("Failed to save image: \(error)") + /// } + /// ``` + /// - Note: This function should be called from an asynchronous context using `await`. + /// - Throws: An error if the save operation fails. + func saveToPhotoLibrary() async throws { + try await withCheckedThrowingContinuation { continuation in + let continuationWrapper = ContinuationWrapper(continuation: continuation) + UIImageWriteToSavedPhotosAlbum( + self, + self, + #selector(saveCompleted), + Unmanaged.passRetained(continuationWrapper).toOpaque() + ) + } + } + + /// Downsizes the image to the specified target size, asynchronously, respecting aspect ratio. + /// - Parameters: + /// - targetSize: The desired size to which the image should be downsized. + /// - Returns: An optional UIImage that is the result of the downsizing operation. + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// let targetSize = CGSize(width: 100, height: 100) + /// let resizedImage = await image.downsize(toTargetSize: targetSize) + /// imageView.image = resizedImage + /// } catch { + /// print("Failed to downsize image: \(error)") + /// } + /// ``` + /// - Note: This function should be called from an asynchronous context using `await`. + func downsize(toTargetSize targetSize: CGSize) async throws -> UIImage { + guard !targetSize.width.isZero, !targetSize.height.isZero else { + throw ImageError.invalidSize + } + + return await Task(priority: .high) { + let widthRatio = targetSize.width / size.width + let heightRatio = targetSize.height / size.height + let scaleFactor = min(widthRatio, heightRatio) + let newSize = CGSize( + width: floor(size.width * scaleFactor), + height: floor(size.height * scaleFactor) + ) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let newImage = renderer.image { _ in + draw(in: CGRect(origin: .zero, size: newSize)) + } + + return newImage + }.value + } + + /// Downsizes the image by the specified percentage asynchronously. + /// - Parameters: + /// - percentage: The percentage by which the image should be downsized. Must be greater than 0 and less than or equal to 100. + /// - Returns: A UIImage that is the result of the downsizing operation. + /// + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// let percentage: CGFloat = 50.0 + /// let resizedImage = try await image.downsize(byPercentage: percentage) + /// imageView.image = resizedImage + /// } catch { + /// print("Failed to downsize image: \(error)") + /// } + /// ``` + /// - Note: This function should be called from an asynchronous context using `await`. + /// - Throws: `ImageError.invalidPercentage` if the percentage is not within the valid range. + func downsize(byPercentage percentage: CGFloat) async throws -> UIImage { + guard percentage > 0 && percentage <= 100 else { + throw ImageError.invalidPercentage + } + + return await Task(priority: .high) { + let scaleFactor = percentage / 100.0 + let newSize = CGSize( + width: size.width * scaleFactor, + height: size.height * scaleFactor + ) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let newImage = renderer.image { _ in + draw(in: CGRect(origin: .zero, size: newSize)) + } + + return newImage + }.value + } + + /// Compresses the image to the specified format. + /// + /// This function compresses UIImage to the specified format (JPEG or PNG). + /// It returns the compressed image data. + /// If the compression operation fails, the function throws an error. + /// + /// - Parameters: + /// - format: The desired image format (JPEG or PNG). + /// - Returns: The compressed image data as `Data`. + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// let compressedData = try await image.compress(to: .jpeg(compressionRatio: 0.8)) + /// } catch { + /// print("Failed to compress image: \(error)") + /// } + /// ``` + /// - Throws: `ImageError.compression` if the compression operation fails. + func compress(toFormat format: ImageFormat) async throws -> Data { + try await Task(priority: .high) { + let compressedImage: Data? + switch format { + case .jpeg(let compressionRatio): + compressedImage = jpegData(compressionQuality: compressionRatio) + case .png: + compressedImage = pngData() + } + + guard let compressedImage else { throw ImageError.compression } + + Logger.debug("Image compressed to \(Double(compressedImage.count) / 1024.0) KB.") + return compressedImage + }.value + } + + /// Compresses the image to given `maxKbSize`. + /// + /// This function compresses UIImage to the specified size in KB. + /// It returns the compressed image data. + /// If the compression operation fails, the function throws an error. + /// + /// - Parameters: + /// - maxSizeInKb: The desired max size in KB. + /// - Returns: The compressed image data as `Data`. + /// - Example: + /// ```swift + /// do { + /// let image = UIImage(named: "exampleImage")! + /// let compressedData = try await image.compress(toMaxKbSize: 500) + /// } catch { + /// print("Failed to compress image: \(error)") + /// } + /// ``` + /// - Throws: `ImageError.compression` if the compression operation fails. + func compress(toMaxKbSize maxKbSize: CGFloat) async throws -> Data { + guard maxKbSize > 0 else { throw ImageError.invalidSize } + + return try await Task(priority: .high) { + let maxBytes = Int(maxKbSize * 1024) + let compressionStep: CGFloat = 0.05 + var compression: CGFloat = 1.0 + var compressedData: Data? + + // try to compress the image by reducing the quality until reached desired `maxSizeInKb` + while compression > 0.0 { + let data = try await compress(toFormat: .jpeg(compressionRatio: compression)) + if data.count <= maxBytes { + compressedData = data + break + } else { + compression -= compressionStep + } + } + + guard let compressedData else { throw ImageError.compression } + return compressedData + }.value + } + + enum ImageFormat { + case jpeg(compressionRatio: CGFloat) + case png + } +} + +// MARK: - Private Methods +private extension UIImage { + class ContinuationWrapper { + let continuation: CheckedContinuation + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + } + + @objc func saveCompleted(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { + let continuationWrapper = Unmanaged.fromOpaque(contextInfo).takeRetainedValue() + + if let error = error { + continuationWrapper.continuation.resume(throwing: error) + } else { + continuationWrapper.continuation.resume() + } + } + + enum ImageError: LocalizedError { + case invalidPercentage + case compression + case invalidSize + } } #endif diff --git a/Tests/Tests/Core/Extensions/UIKit/UIImageTests.swift b/Tests/Tests/Core/Extensions/UIKit/UIImageTests.swift new file mode 100644 index 00000000..3e8909be --- /dev/null +++ b/Tests/Tests/Core/Extensions/UIKit/UIImageTests.swift @@ -0,0 +1,117 @@ +// +// UIImageTests.swift +// PovioKit_Tests +// +// Created by Borut Tomazin on 26/09/2024. +// Copyright © 2024 Povio Inc. All rights reserved. +// + +#if os(iOS) +import XCTest +import UIKit +import PovioKitCore + +class UIImageTests: XCTestCase { + private let image: UIImage? = UIImage(named: "PovioKit", in: .module, with: nil) + + func testDownsizeToTargetSize() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Downsizing...") + + Task { + do { + let downsizedImage = try await image.downsize(toTargetSize: .init(size: 200)) + XCTAssertLessThanOrEqual(downsizedImage.size.width, 200, "The width of the downsized image should be 200") + XCTAssertLessThanOrEqual(downsizedImage.size.height, 200, "The height of the downsized image should be 200") + } catch { + XCTFail("The image failed to downsize.") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testDownsizeByPercentage() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Downsizing...") + let originalSize = image.size + + Task { + do { + let downsizedImage = try await image.downsize(byPercentage: 50) + XCTAssertEqual(downsizedImage.size.width, originalSize.width / 2, "The width of the downsized image should be 200") + XCTAssertEqual(downsizedImage.size.height, originalSize.height / 2, "The height of the downsized image should be 200") + } catch { + XCTFail("The image failed to downsize.") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testCompressWithPngFormat() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Compressing...") + + Task { + do { + let downsizedImage = try await image.compress(toFormat: .png) + // verify the image format by checking the PNG signature + let pngSignature: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + let downsizedImageSignature = [UInt8](downsizedImage.prefix(pngSignature.count)) + + XCTAssertEqual(downsizedImageSignature, pngSignature, "The image was not compressed as PNG.") + } catch { + XCTFail("The image failed to compress.") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testCompressWithJpgFormat() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Compressing...") + + Task { + do { + let downsizedImage = try await image.compress(toFormat: .jpeg(compressionRatio: 0.5)) + // verify the image format by checking the JPEG signature + let jpegSignature: [UInt8] = [0xFF, 0xD8] + let downsizedImageSignature = [UInt8](downsizedImage.prefix(jpegSignature.count)) + + XCTAssertEqual(downsizedImageSignature, jpegSignature, "The image was not compressed as JPEG.") + } catch { + XCTFail("The image failed to compress.") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testCompressToMaxSize() { + guard let image else { XCTFail("Failed to load image"); return } + let promise = expectation(description: "Compressing...") + + let targetKB = 10.0 + + Task { + do { + let downsizedImage = try await image.compress(toMaxKbSize: targetKB) + // Check if the size is 1KB or less + let imageSizeInKB = Double(downsizedImage.count) / 1024.0 + XCTAssertLessThanOrEqual(imageSizeInKB, targetKB, "The compressed image size is greater than 1KB.") + } catch { + XCTFail("The image failed to compress. \(error)") + } + promise.fulfill() + } + + waitForExpectations(timeout: 1) + } +} +#endif diff --git a/Tests/Tests/Core/Utilities/BundleReader/BundleReaderTests.swift b/Tests/Tests/Core/Utilities/BundleReader/BundleReaderTests.swift index aae4e73b..314ec884 100644 --- a/Tests/Tests/Core/Utilities/BundleReader/BundleReaderTests.swift +++ b/Tests/Tests/Core/Utilities/BundleReader/BundleReaderTests.swift @@ -40,7 +40,8 @@ private extension BundleReaderTests { return (sut, reader) } } -private class BundleSpy: Bundle { + +private class BundleSpy: Bundle, @unchecked Sendable { private(set) var capturedRead: String? private var internalDictionary: [String: String] = [:] override func object(forInfoDictionaryKey key: String) -> Any? { diff --git a/Tests/Tests/Resources/PovioKit.png b/Tests/Tests/Resources/PovioKit.png new file mode 100644 index 00000000..45386ca6 Binary files /dev/null and b/Tests/Tests/Resources/PovioKit.png differ