diff --git a/Example/BillboardExample.xcodeproj/project.pbxproj b/Example/BillboardExample.xcodeproj/project.pbxproj index 34bef9f..918b003 100644 --- a/Example/BillboardExample.xcodeproj/project.pbxproj +++ b/Example/BillboardExample.xcodeproj/project.pbxproj @@ -282,6 +282,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -289,9 +290,11 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.modumhq.BillboardExample; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,3"; }; name = Debug; }; @@ -310,6 +313,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -317,9 +321,11 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.modumhq.BillboardExample; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,3"; }; name = Release; }; diff --git a/Example/BillboardExample/ContentView.swift b/Example/BillboardExample/ContentView.swift index 6003642..562d7c5 100644 --- a/Example/BillboardExample/ContentView.swift +++ b/Example/BillboardExample/ContentView.swift @@ -13,17 +13,18 @@ struct ContentView: View { @StateObject var premium = PremiumStore() @State private var showRandomAdvert = false - @State private var adtoshow :BillboardAd? = nil - @State private var allAds : [BillboardAd] = [] + @State private var adtoshow: BillboardAd? = nil + @State private var allAds: [BillboardAd] = [] + @State private var bannerAd: BillboardAd? = nil let config = BillboardConfiguration(advertDuration: 5) var body: some View { NavigationStack { List { - if let advert = allAds.randomElement() { + if let bannerAd { Section { - BillboardBannerView(advert: advert, hideDismissButtonAndTimer: true) + BillboardBannerView(advert: bannerAd, hideDismissButtonAndTimer: true) .listRowBackground(Color.clear) .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) } @@ -62,11 +63,11 @@ struct ContentView: View { } } } - .font(.compatibleSystem(.body, design: .rounded, weight: .medium)) + .font(.system(.body, design: .rounded, weight: .medium)) } .safeAreaInset(edge: .bottom, content: { - if let advert = allAds.randomElement() { - BillboardBannerView(advert: advert) + if let bannerAd { + BillboardBannerView(advert: bannerAd) .padding() } @@ -75,14 +76,15 @@ struct ContentView: View { Task { if let allAds = try? await BillboardViewModel.fetchAllAds(from: config.adsJSONURL!) { self.allAds = allAds + self.bannerAd = allAds.randomElement() } } } - .onChange(of: premium.didBuyPremium) { newValue in - if newValue { - showRandomAdvert = !newValue + .onChange(of: premium.didBuyPremium, { + if premium.didBuyPremium { + showRandomAdvert = !premium.didBuyPremium } - } + }) .showBillboard(when: $showRandomAdvert) { // Replace this view with your Paywall VStack { @@ -102,6 +104,7 @@ struct ContentView: View { if let allAds = try? await BillboardViewModel.fetchAllAds(from: config.adsJSONURL!) { self.allAds = allAds + bannerAd = allAds.randomElement() } } } diff --git a/Package.swift b/Package.swift index 0c93869..bea4824 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,8 +6,9 @@ import PackageDescription let package = Package( name: "Billboard", platforms: [ - .iOS(.v15), - .tvOS(.v16) + .iOS(.v17), + .visionOS(.v1), + .tvOS(.v17) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. @@ -21,5 +22,6 @@ let package = Package( .testTarget( name: "BillboardTests", dependencies: ["Billboard"]), - ] + ], + swiftLanguageModes: [.v5] ) diff --git a/README.md b/README.md index 7c2e74b..a0b6471 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Billboard is a module that enables the incorporation of advertisement highlights ## Installation -Ready to use on iOS 15+. +Ready to use on iOS 16+, tvOS 17+ and visionOS 1+. 1. In Xcode, select **Add Packages…** from the File menu. 2. Enter `https://github.com/hiddevdploeg/Billboard` in the search field. @@ -171,7 +171,8 @@ Here's an example of how your source list could look like. "textColor" : "EFDED7", "tintColor" : "EFDED7", "fullscreen": false, - "transparent": true + "transparent": true, + "adCategory": "music" } ] } diff --git a/Sources/Billboard/CachedImage/CachedImageManager.swift b/Sources/Billboard/CachedImage/CachedImageManager.swift deleted file mode 100644 index 12c9cea..0000000 --- a/Sources/Billboard/CachedImage/CachedImageManager.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// CachedImageManager.swift -// AsyncImageStarter -// -// Created by Tunde Adegoroye on 09/04/2022. -// - -import Foundation - -final class CachedImageManager: ObservableObject { - - @Published private(set) var currentState: CurrentState? - - private let imageRetriver = ImageRetriver() - - @MainActor - func load(_ imgUrl: String, - cache: ImageCache = .shared) async { - - self.currentState = .loading - - if let imageData = cache.object(forkey: imgUrl as NSString) { - self.currentState = .success(data: imageData) - return - } - - do { - let data = try await imageRetriver.fetch(imgUrl) - self.currentState = .success(data: data) - cache.set(object: data as NSData, - forKey: imgUrl as NSString) - } catch { - self.currentState = .failed(error: error) - } - } -} - -extension CachedImageManager { - enum CurrentState { - case loading - case failed(error: Error) - case success(data: Data) - } -} - -extension CachedImageManager.CurrentState: Equatable { - static func == (lhs: CachedImageManager.CurrentState, - rhs: CachedImageManager.CurrentState) -> Bool { - switch (lhs, rhs) { - case (.loading, .loading): - return true - case (let .failed(lhsError), let .failed(rhsError)): - return lhsError.localizedDescription == rhsError.localizedDescription - case (let .success(lhsData), let .success(rhsData)): - return lhsData == rhsData - default: - return false - } - } -} diff --git a/Sources/Billboard/Utilities/Font+iOS15.swift b/Sources/Billboard/Utilities/Font+iOS15.swift deleted file mode 100644 index cba6a0e..0000000 --- a/Sources/Billboard/Utilities/Font+iOS15.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Font+iOS15.swift -// -// -// Created by Engin Kurutepe on 04.07.23. -// - -import SwiftUI - -extension Font { - public static func compatibleSystem(_ style: TextStyle, design: Design?, weight: Weight?) -> Font { - if #available(iOS 16.0, tvOS 16.0, *) { - return .system(style, design: design, weight: weight) - } else { - return .system(style, design: design ?? .default).weight(weight ?? .regular) - } - } -} diff --git a/Sources/Billboard/Utilities/Logger+Ext.swift b/Sources/Billboard/Utilities/Logger+Ext.swift deleted file mode 100644 index ee65929..0000000 --- a/Sources/Billboard/Utilities/Logger+Ext.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Logger+Ext.swift -// -// -// Created by Hidde van der Ploeg on 29/10/2023. -// - -import OSLog - -extension Logger { - /// Using your bundle identifier is a great way to ensure a unique identifier. - private static var subsystem = Bundle.main.bundleIdentifier! - - /// Logs coming from the Billboard SPM - static let billboard = Logger(subsystem: subsystem, category: "Billboard") -} diff --git a/Sources/Billboard/Views/BillboardDismissButton.swift b/Sources/Billboard/Views/BillboardDismissButton.swift deleted file mode 100644 index 0afc0d2..0000000 --- a/Sources/Billboard/Views/BillboardDismissButton.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// BillboardDismissButton.swift -// -// -// Created by Hidde van der Ploeg on 01/07/2023. -// - -import SwiftUI - -struct BillboardDismissButton : View { - @Environment(\.dismiss) var dismiss - - var body: some View { - Button { - dismiss() - } label: { - #if os(visionOS) - Label("Dismiss advertisement", systemImage: "xmark") - .labelStyle(.iconOnly) - #else - Label("Dismiss advertisement", systemImage: "xmark.circle.fill") - .labelStyle(.iconOnly) - .font(.compatibleSystem(.title2, design: .rounded, weight: .bold)) - .symbolRenderingMode(.hierarchical) - .imageScale(.large) -#if !os(tvOS) - .controlSize(.large) - #endif - #endif - } - } -} diff --git a/Sources/Billboard/Views/BillboardTextView.swift b/Sources/Billboard/Views/BillboardTextView.swift deleted file mode 100644 index 08cde97..0000000 --- a/Sources/Billboard/Views/BillboardTextView.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// BillboardTextView.swift -// -// -// Created by Hidde van der Ploeg on 01/07/2023. -// - -import SwiftUI - -struct BillboardTextView : View { - let advert: BillboardAd - - var body: some View { - VStack(spacing: 10) { - BillboardAdInfoLabel(advert: advert) - - VStack(spacing: 6) { - Text(advert.title) - .font(.compatibleSystem(.title2, design: .rounded, weight: .heavy)) - Text(advert.description) - .font(.system(.body, design: .rounded)) - } - } - .multilineTextAlignment(.center) - .foregroundColor(advert.text) - .frame(maxWidth: 640) - .padding(.horizontal, 24) - .padding(.bottom, 64) - } -} - -struct BillboardTextView_Previews: PreviewProvider { - static var previews: some View { - DefaultAdView(advert: BillboardSamples.sampleDefaultAd) - } -} - - diff --git a/Sources/Billboard/BillboardConfiguration.swift b/Sources/BillboardConfiguration.swift similarity index 61% rename from Sources/Billboard/BillboardConfiguration.swift rename to Sources/BillboardConfiguration.swift index aa92079..e392dea 100644 --- a/Sources/Billboard/BillboardConfiguration.swift +++ b/Sources/BillboardConfiguration.swift @@ -21,12 +21,20 @@ public struct BillboardConfiguration { /// Provide a list of Apple ID's that you want to exclude from showing up (e.g. your own app) public let excludedIDs : [String] - public init(adsJSONURL: URL? = URL(string:"https://billboard-source.vercel.app/ads.json"), - allowHaptics: Bool = true, - advertDuration: TimeInterval = 15.0, excludedIDs: [String] = []) { + /// All Categories that should be included in the ads that are shown + public let categories : [String] + + public init( + adsJSONURL: URL? = URL(string:"https://billboard-source.vercel.app/ads.json"), + allowHaptics: Bool = true, + advertDuration: TimeInterval = 15.0, + excludedIDs: [String] = [], + categories: [AdCategory] = AdCategory.allCases + ) { self.adsJSONURL = adsJSONURL self.allowHaptics = allowHaptics self.duration = advertDuration self.excludedIDs = excludedIDs + self.categories = categories.map { $0.rawValue } } } diff --git a/Sources/Billboard/BillboardViewModel.swift b/Sources/BillboardViewModel.swift similarity index 86% rename from Sources/Billboard/BillboardViewModel.swift rename to Sources/BillboardViewModel.swift index 7e21e1b..3ddb10b 100644 --- a/Sources/Billboard/BillboardViewModel.swift +++ b/Sources/BillboardViewModel.swift @@ -38,7 +38,10 @@ public final class BillboardViewModel : ObservableObject { } } - public static func fetchRandomAd(excludedIDs: [String] = []) async throws -> BillboardAd? { + public static func fetchRandomAd( + excludedIDs: [String] = [], + categories: [AdCategory] = AdCategory.allCases + ) async throws -> BillboardAd? { guard let url = BillboardConfiguration().adsJSONURL else { return nil } @@ -50,7 +53,12 @@ public final class BillboardViewModel : ObservableObject { let (data, _) = try await session.data(from: url) let decoder = JSONDecoder() let response = try decoder.decode(BillboardAdResponse.self, from: data) - let filteredAds = response.ads.filter({ !excludedIDs.contains($0.appStoreID) }) + var filteredAds = response.ads.filter({ !excludedIDs.contains($0.appStoreID) }) + + if categories.count != AdCategory.allCases.count { + filteredAds = filteredAds.filter({ categories.contains($0.category) }) + } + let adToShow = filteredAds.randomElement() if let adToShow { @@ -74,7 +82,11 @@ public final class BillboardViewModel : ObservableObject { return nil } - public static func fetchRandomAd(from url: URL, excludedIDs: [String] = []) async throws -> BillboardAd? { + public static func fetchRandomAd( + from url: URL, + excludedIDs: [String] = [], + categories: [AdCategory] = AdCategory.allCases + ) async throws -> BillboardAd? { let session = URLSession(configuration: BillboardViewModel.networkConfiguration) session.sessionDescription = "Fetching Billboard Ad" @@ -82,7 +94,13 @@ public final class BillboardViewModel : ObservableObject { let (data, _) = try await session.data(from: url) let decoder = JSONDecoder() let response = try decoder.decode(BillboardAdResponse.self, from: data) - let filteredAds = response.ads.filter({ !excludedIDs.contains($0.appStoreID) }) + + var filteredAds = response.ads.filter({ !excludedIDs.contains($0.appStoreID) }) + + if categories.count != AdCategory.allCases.count { + filteredAds = filteredAds.filter({ categories.contains($0.category) }) + } + let adToShow = filteredAds.randomElement() if let adToShow { diff --git a/Sources/Billboard/CachedImage/CachedImage.swift b/Sources/CachedImage/CachedImage.swift similarity index 85% rename from Sources/Billboard/CachedImage/CachedImage.swift rename to Sources/CachedImage/CachedImage.swift index 9124a4e..ab7d9cf 100644 --- a/Sources/Billboard/CachedImage/CachedImage.swift +++ b/Sources/CachedImage/CachedImage.swift @@ -7,15 +7,16 @@ import SwiftUI -struct CachedImage: View { +public struct CachedImage: View { - @StateObject private var manager = CachedImageManager() - let url: String - let animation: Animation? - let transition: AnyTransition - let content: (AsyncImagePhase) -> Content + @State private var manager = CachedImageManager() - init(url: String, + public let url: String + public let animation: Animation? + public let transition: AnyTransition + public let content: (AsyncImagePhase) -> Content + + public init(url: String, animation: Animation? = nil, transition: AnyTransition = .identity, @ViewBuilder content: @escaping (AsyncImagePhase) -> Content) { diff --git a/Sources/CachedImage/CachedImageManager.swift b/Sources/CachedImage/CachedImageManager.swift new file mode 100644 index 0000000..3a09ef8 --- /dev/null +++ b/Sources/CachedImage/CachedImageManager.swift @@ -0,0 +1,37 @@ +// +// CachedImageManager.swift +// AsyncImageStarter +// +// Created by Tunde Adegoroye on 09/04/2022. +// + +import Foundation +import Observation +@Observable public final class CachedImageManager { + + private(set) var currentState: CachedImageState? + + private let imageRetriver = ImageRetriver() + + @MainActor + public func load(_ imgUrl: String, + cache: ImageCache = .shared) async { + + self.currentState = .loading + + if let imageData = cache.object(forkey: imgUrl as NSString) { + self.currentState = .success(data: imageData) + return + } + + do { + let data = try await imageRetriver.fetch(imgUrl) + self.currentState = .success(data: data) + cache.set(object: data as NSData, + forKey: imgUrl as NSString) + } catch { + self.currentState = .failed(error: error) + } + } +} + diff --git a/Sources/CachedImage/CachedImageState.swift b/Sources/CachedImage/CachedImageState.swift new file mode 100644 index 0000000..906c00c --- /dev/null +++ b/Sources/CachedImage/CachedImageState.swift @@ -0,0 +1,28 @@ +// +// CachedImageState.swift +// Billboard +// +// Created by Hidde van der Ploeg on 26/11/2024. +// + + +import Foundation +public enum CachedImageState: Equatable { + case loading + case failed(error: Error) + case success(data: Data) + + public static func == (lhs: CachedImageState, + rhs: CachedImageState) -> Bool { + switch (lhs, rhs) { + case (.loading, .loading): + return true + case (let .failed(lhsError), let .failed(rhsError)): + return lhsError.localizedDescription == rhsError.localizedDescription + case (let .success(lhsData), let .success(rhsData)): + return lhsData == rhsData + default: + return false + } + } +} diff --git a/Sources/Billboard/CachedImage/ImageCache.swift b/Sources/CachedImage/ImageCache.swift similarity index 71% rename from Sources/Billboard/CachedImage/ImageCache.swift rename to Sources/CachedImage/ImageCache.swift index d625a5e..44a49a5 100644 --- a/Sources/Billboard/CachedImage/ImageCache.swift +++ b/Sources/CachedImage/ImageCache.swift @@ -7,11 +7,12 @@ import Foundation -class ImageCache { + +public class ImageCache: @unchecked Sendable { typealias CacheType = NSCache - static let shared = ImageCache() + public static let shared = ImageCache() private init() {} @@ -22,11 +23,11 @@ class ImageCache { return cache }() - func object(forkey key: NSString) -> Data? { + public func object(forkey key: NSString) -> Data? { cache.object(forKey: key) as? Data } - func set(object: NSData, forKey key: NSString) { + public func set(object: NSData, forKey key: NSString) { cache.setObject(object, forKey: key) } } diff --git a/Sources/Billboard/CachedImage/ImageRetriever.swift b/Sources/CachedImage/ImageRetriever.swift similarity index 100% rename from Sources/Billboard/CachedImage/ImageRetriever.swift rename to Sources/CachedImage/ImageRetriever.swift diff --git a/Sources/Models/AdCategory.swift b/Sources/Models/AdCategory.swift new file mode 100644 index 0000000..6882174 --- /dev/null +++ b/Sources/Models/AdCategory.swift @@ -0,0 +1,59 @@ +// +// AdCategory.swift +// Billboard +// +// Created by Hidde van der Ploeg on 29/11/2024. +// + + +public enum AdCategory: String, CaseIterable, Sendable { + case none + case books + case business + case developerTools + case education + case entertainment + case finance + case foodAndDrink + case games + case graphicsAndDesign + case health + case lifestyle + case magazinesAndNewspapers + case medical + case music + case navigation + case news + case photoAndVideo + case productivity + case reference + case shopping + case socialNetworking + case sports + case stickers + case travel + case utilities + case weather + + + public var title: String { + switch self { + case .developerTools: + "Developer Tools" + case .foodAndDrink: + "Food & Drink" + case .graphicsAndDesign: + "Graphics & Design" + case .health: + "Health & Fitness" + case .magazinesAndNewspapers: + "Magazines & Newspapers" + case .photoAndVideo: + "Photo & Video" + case .socialNetworking: + "Social Networking" + default: + self.rawValue.capitalized + } + } +} diff --git a/Sources/Models/AppIconResponse.swift b/Sources/Models/AppIconResponse.swift new file mode 100644 index 0000000..1ff0650 --- /dev/null +++ b/Sources/Models/AppIconResponse.swift @@ -0,0 +1,11 @@ +// +// AppIconResponse.swift +// Billboard +// +// Created by Hidde van der Ploeg on 26/11/2024. +// + + +public struct AppIconResponse: Codable, Sendable { + let results: [AppIconResult] +} diff --git a/Sources/Models/AppIconResult.swift b/Sources/Models/AppIconResult.swift new file mode 100644 index 0000000..76fae41 --- /dev/null +++ b/Sources/Models/AppIconResult.swift @@ -0,0 +1,12 @@ +// +// AppIconResult.swift +// Billboard +// +// Created by Hidde van der Ploeg on 26/11/2024. +// + + +public struct AppIconResult: Codable, Sendable { + let artworkUrl100: String + +} diff --git a/Sources/Billboard/Models/BillboardAd.swift b/Sources/Models/BillboardAd.swift similarity index 61% rename from Sources/Billboard/Models/BillboardAd.swift rename to Sources/Models/BillboardAd.swift index 89f62e2..999e73f 100644 --- a/Sources/Billboard/Models/BillboardAd.swift +++ b/Sources/Models/BillboardAd.swift @@ -8,8 +8,34 @@ import Foundation import SwiftUI -public struct BillboardAd : Codable, Identifiable, Hashable { +public struct BillboardAd: Codable, Identifiable, Hashable, Sendable { + public init( + appStoreID: String, + name: String, + title: String, + description: String, + category: AdCategory? = nil, + media: URL, + backgroundColor: String, + textColor: String, + tintColor: String, + fullscreen: Bool, + transparent: Bool + ) { + self.appStoreID = appStoreID + self.name = name + self.title = title + self.description = description + self.media = media + self.backgroundColor = backgroundColor + self.textColor = textColor + self.tintColor = tintColor + self.adCategory = category?.rawValue + self.fullscreen = fullscreen + self.transparent = transparent + } + public static func == (lhs: BillboardAd, rhs: BillboardAd) -> Bool { lhs.id == rhs.id } @@ -23,55 +49,61 @@ public struct BillboardAd : Codable, Identifiable, Hashable { } /// Should be the Apple ID of App that's connected to the Ad (e.g. 1596487035) - public let appStoreID : String + public let appStoreID: String /// Name of ad (e.g. NowPlaying) - public let name : String + public let name: String /// Title that's displayed on the Ad (Recommended to be no more than 25 characters) - public let title : String + public let title: String /// Description that's displayed on the Ad (Recommended to be no more than 140 characters) - public let description : String + public let description: String /// URL of image that's used in the Ad - public let media : URL + public let media: URL /// App Store Link based on `appStoreID` - public var appStoreLink : URL? { + public var appStoreLink: URL? { return URL(string: "https://apps.apple.com/app/id\(appStoreID)") } /// Main Background color in HEX format - public let backgroundColor : String + public let backgroundColor: String /// Text color in HEX format - public let textColor : String + public let textColor: String /// Main tint color in HEX format - public let tintColor : String + public let tintColor: String + + + public let adCategory: String? /// For fullscreen media styling (should be true when the main image is a photo) public let fullscreen: Bool /// Allows blurred background when the main image is a PNG - public let transparent : Bool + public let transparent: Bool - public var background : Color { + public var background: Color { return Color(hex: self.backgroundColor) } - public var text : Color { + public var text: Color { return Color(hex: self.textColor) } - public var tint : Color { + public var tint: Color { return Color(hex: self.tintColor) } + public var category: AdCategory { + AdCategory(rawValue: adCategory ?? "") ?? .none + } - public var appIconURL : URL? { + public var appIconURL: URL? { return URL(string: "http://itunes.apple.com/lookup?id=\(appStoreID)") } @@ -95,12 +127,6 @@ public struct BillboardAd : Codable, Identifiable, Hashable { } } -public struct AppIconResponse : Codable { - let results: [AppIconResult] -} -public struct AppIconResult : Codable { - let artworkUrl100: String -} diff --git a/Sources/Billboard/Models/BillboardAdResponse.swift b/Sources/Models/BillboardAdResponse.swift similarity index 100% rename from Sources/Billboard/Models/BillboardAdResponse.swift rename to Sources/Models/BillboardAdResponse.swift diff --git a/Sources/Billboard/Utilities/AdvertisementViewModifier.swift b/Sources/Utilities/AdvertisementViewModifier.swift similarity index 93% rename from Sources/Billboard/Utilities/AdvertisementViewModifier.swift rename to Sources/Utilities/AdvertisementViewModifier.swift index 03bcc85..489561e 100644 --- a/Sources/Billboard/Utilities/AdvertisementViewModifier.swift +++ b/Sources/Utilities/AdvertisementViewModifier.swift @@ -35,13 +35,13 @@ public struct AdvertisementModifier: ViewModifier { public func body(content: Content) -> some View { content - .onChange(of: showAd.wrappedValue) { show in - if show { + .onChange(of: showAd.wrappedValue, { + if showAd.wrappedValue { Task { await monitor.showAdvertisement() } } - } + }) .fullScreenCover(item: $monitor.advertisement, onDismiss: { showAd.wrappedValue = false }) { advert in BillboardView(advert: advert, config: config, paywall: { paywall() }) } diff --git a/Sources/Billboard/Utilities/BillboardSamples.swift b/Sources/Utilities/BillboardSamples.swift similarity index 96% rename from Sources/Billboard/Utilities/BillboardSamples.swift rename to Sources/Utilities/BillboardSamples.swift index 67efdc0..04c3c7c 100644 --- a/Sources/Billboard/Utilities/BillboardSamples.swift +++ b/Sources/Utilities/BillboardSamples.swift @@ -14,6 +14,7 @@ public struct BillboardSamples { name: "NowPlaying", title: "Learn everything about any song", description: "A music companion app that lets you discover the stories behind and song, album or artist.", + category: .music, media: URL(string: "https://pub-378e0dd96b5343108a04317ebddebb4e.r2.dev/nowplaying.png")!, backgroundColor: "344442", textColor: "EFDED7", diff --git a/Sources/Billboard/Utilities/Color+Hex.swift b/Sources/Utilities/Color+Hex.swift similarity index 100% rename from Sources/Billboard/Utilities/Color+Hex.swift rename to Sources/Utilities/Color+Hex.swift diff --git a/Sources/Billboard/Utilities/Haptics.swift b/Sources/Utilities/Haptics.swift similarity index 100% rename from Sources/Billboard/Utilities/Haptics.swift rename to Sources/Utilities/Haptics.swift diff --git a/Sources/Utilities/Logger+Ext.swift b/Sources/Utilities/Logger+Ext.swift new file mode 100644 index 0000000..64343d4 --- /dev/null +++ b/Sources/Utilities/Logger+Ext.swift @@ -0,0 +1,13 @@ +// +// Logger+Ext.swift +// +// +// Created by Hidde van der Ploeg on 29/10/2023. +// + +import OSLog + +extension Logger { + /// Logs coming from the Billboard SPM + static let billboard = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Billboard Package", category: "Billboard") +} diff --git a/Sources/Utilities/QRCodeView.swift b/Sources/Utilities/QRCodeView.swift new file mode 100644 index 0000000..94a9c72 --- /dev/null +++ b/Sources/Utilities/QRCodeView.swift @@ -0,0 +1,66 @@ +// +// QRCodeView.swift +// Billboard +// +// Created by Hidde van der Ploeg on 26/11/2024. +// + +import SwiftUI +import CoreImage.CIFilterBuiltins + +public extension URL { + /// A SwiftUI View that displays the URL as a QR code + var qrCodeView: some View { + QRCodeView(url: self) + } +} + +public struct QRCodeView: View { + public let url: URL + + public init(url: URL) { + self.url = url + } + + // Create a CIContext for rendering the QR code + private let context = CIContext() + + // Create the QR code generator filter + private var qrCodeGenerator: CIFilter { + let filter = CIFilter.qrCodeGenerator() + let data = url.absoluteString.data(using: .utf8) + filter.setValue(data, forKey: "inputMessage") + // You can adjust the correction level if needed + // filter.setValue("H", forKey: "inputCorrectionLevel") + return filter + } + + // Generate the QR code image + private var qrCodeImage: UIImage? { + guard let outputImage = qrCodeGenerator.outputImage else { return nil } + + // Scale the image to a reasonable size + let transform = CGAffineTransform(scaleX: 10, y: 10) + let scaledImage = outputImage.transformed(by: transform) + + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { + return nil + } + + return UIImage(cgImage: cgImage) + } + + public var body: some View { + Group { + if let image = qrCodeImage { + Image(uiImage: image) + .interpolation(.none) + .resizable() + .scaledToFit() + } else { + Text("Failed to generate QR code") + .foregroundColor(.red) + } + } + } +} diff --git a/Sources/Billboard/Views/BillboardAdInfoLabel.swift b/Sources/Views/BillboardAdInfoLabel.swift similarity index 60% rename from Sources/Billboard/Views/BillboardAdInfoLabel.swift rename to Sources/Views/BillboardAdInfoLabel.swift index 8247f42..1a0c757 100644 --- a/Sources/Billboard/Views/BillboardAdInfoLabel.swift +++ b/Sources/Views/BillboardAdInfoLabel.swift @@ -11,6 +11,19 @@ struct BillboardAdInfoLabel: View { let advert : BillboardAd var body: some View { + #if os(tvOS) + Text("AD") + .font(.system(.caption, design: .rounded, weight: .heavy).smallCaps()) + .fixedSize(horizontal: true, vertical: false) + .foregroundColor(advert.tint) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .accessibilityLabel(Text("Advertisement")) + .background { + RoundedRectangle(cornerRadius: 4) + .fill(advert.tint.quinary) + } + #else ZStack { RoundedRectangle(cornerRadius: 4, style: .continuous) .fill(advert.tint.opacity(0.15)) @@ -21,6 +34,7 @@ struct BillboardAdInfoLabel: View { } .frame(width: 22, height: 14) .accessibilityLabel(Text("Advertisement")) + #endif } } diff --git a/Sources/Billboard/Views/BillboardBannerView.swift b/Sources/Views/BillboardBannerView.swift similarity index 60% rename from Sources/Billboard/Views/BillboardBannerView.swift rename to Sources/Views/BillboardBannerView.swift index 7f3310d..d9704f6 100644 --- a/Sources/Billboard/Views/BillboardBannerView.swift +++ b/Sources/Views/BillboardBannerView.swift @@ -5,6 +5,7 @@ // import SwiftUI +import OSLog public struct BillboardBannerView : View { @Environment(\.accessibilityReduceMotion) private var reducedMotion @@ -18,6 +19,7 @@ public struct BillboardBannerView : View { @State private var canDismiss = false @State private var appIcon : UIImage? = nil @State private var showAdvertisement = true + @State private var loadingNewIcon = true public init(advert: BillboardAd, config: BillboardConfiguration = BillboardConfiguration(), includeShadow: Bool = true, hideDismissButtonAndTimer: Bool = false) { self.advert = advert @@ -36,25 +38,42 @@ public struct BillboardBannerView : View { } } label: { HStack(spacing: 10) { - if let appIcon { - Image(uiImage: appIcon) - .resizable() - .frame(width: 60, height: 60) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .accessibilityHidden(true) + ZStack { + if let appIcon, !loadingNewIcon { + Image(uiImage: appIcon) + .resizable() + } else { + advert.tint + ProgressView() + .foregroundStyle(advert.text) + } } +#if os(tvOS) + .frame(width: 120, height: 120) + .clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous)) + #else + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + #endif + .accessibilityHidden(true) + .transition(.blurReplace) + .animation(.default, value: loadingNewIcon) +#if os(tvOS) + .padding(.leading) + #endif + VStack(alignment: .leading, spacing: 4) { BillboardAdInfoLabel(advert: advert) VStack(alignment: .leading) { Text(advert.title) - .font(.compatibleSystem(.footnote, design: .rounded, weight: .bold)) + .font(.system(.footnote, design: .rounded, weight: .bold)) .foregroundColor(advert.text) .lineLimit(1) .minimumScaleFactor(0.75) Text(advert.name) - .font(.compatibleSystem(.caption2, design: .rounded, weight: .medium).smallCaps()) + .font(.system(.caption2, design: .rounded, weight: .medium).smallCaps()) .foregroundColor(advert.tint) .opacity(0.8) } @@ -66,29 +85,37 @@ public struct BillboardBannerView : View { .contentShape(Rectangle()) } .buttonStyle(.plain) - Spacer() Group { if !hideDismissButtonAndTimer { if canDismiss { Button { - #if os(iOS) + #if os(iOS) if config.allowHaptics { haptics(.light) } #endif showAdvertisement = false } label: { +#if os(visionOS) + Label("Dismiss advertisement", systemImage: "xmark") + .labelStyle(.iconOnly) + .font(.system(.title3, design: .rounded, weight: .bold)) + .symbolRenderingMode(.hierarchical) +#else Label("Dismiss advertisement", systemImage: "xmark.circle.fill") .labelStyle(.iconOnly) - .font(.compatibleSystem(.title2, design: .rounded, weight: .bold)) + .font(.system(.title2, design: .rounded, weight: .bold)) .symbolRenderingMode(.hierarchical) .imageScale(.large) -#if !os(tvOS) - .controlSize(.large) - #endif +#endif } +#if !os(tvOS) + .controlSize(.large) +#endif +#if !os(visionOS) .tint(advert.tint) +#endif } else { BillboardCountdownView(advert:advert, totalDuration: config.duration, @@ -112,54 +139,41 @@ public struct BillboardBannerView : View { .transaction { if reducedMotion { $0.animation = nil } } - .onChange(of: advert) { _ in - Task { - await fetchAppIcon() - } - } - + .onChange(of: advert, { + Task { await fetchAppIcon() } + }) } private func fetchAppIcon() async { - if let data = try? await advert.getAppIcon() { - await MainActor.run { - appIcon = UIImage(data: data) + do { + loadingNewIcon = true + let imageData = try await advert.getAppIcon() + if let imageData { + await MainActor.run { + appIcon = UIImage(data: imageData) + loadingNewIcon = false + } } + } catch { + Logger.billboard.error("\(error.localizedDescription)") + loadingNewIcon = false } } - + @ViewBuilder var backgroundView : some View { - if #available(iOS 16.0, tvOS 16.0, *) { - ZStack { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(advert.background.gradient) - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.primary.opacity(0.1), lineWidth: 1) - } + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(advert.background.gradient) + .stroke(Color.primary.opacity(0.1), lineWidth: 1) .shadow(color: includeShadow ? advert.background.opacity(0.5) : Color.clear, radius: 6, x: 0, y: 2) - - } else { - ZStack { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(advert.background) - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.primary.opacity(0.1), lineWidth: 1) - } - .shadow(color: includeShadow ? advert.background.opacity(0.5) : Color.clear, radius: 6, x: 0, y: 2) - } } } - -struct BillboardBannerView_Previews: PreviewProvider { - static var previews: some View { - VStack { - BillboardBannerView(advert: BillboardSamples.sampleDefaultAd) - BillboardBannerView(advert: BillboardSamples.sampleDefaultAd, hideDismissButtonAndTimer: true) - } - .padding() - +#Preview { + VStack { + BillboardBannerView(advert: BillboardSamples.sampleDefaultAd) + BillboardBannerView(advert: BillboardSamples.sampleDefaultAd, hideDismissButtonAndTimer: true) } + .padding() } diff --git a/Sources/Billboard/Views/BillboardCountdownView.swift b/Sources/Views/BillboardCountdownView.swift similarity index 60% rename from Sources/Billboard/Views/BillboardCountdownView.swift rename to Sources/Views/BillboardCountdownView.swift index e38b256..d8218f2 100644 --- a/Sources/Billboard/Views/BillboardCountdownView.swift +++ b/Sources/Views/BillboardCountdownView.swift @@ -20,6 +20,43 @@ struct BillboardCountdownView : View { private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { + #if os(tvOS) + ZStack { + Circle() + .stroke(advert.tint.opacity(0.2), style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round)) + + Circle() + .trim(from: 0, to: timerProgress) + .stroke(advert.tint, style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round)) + + Text("\(seconds, specifier: "%.0f")") + .font(.system(.caption, design: .rounded, weight: .heavy).monospacedDigit()) + .rotationEffect(.degrees(90)) + .minimumScaleFactor(0.5) + .animation(.default, value: seconds) + .transition(.identity) + .contentTransition(.numericText(value: seconds)) + .onReceive(timer) { _ in + if seconds > 0 { + seconds -= 1 + } + } + } + .foregroundColor(advert.tint) + .rotationEffect(.degrees(-90)) + .frame(width: 64, height: 64) + .onAppear { + seconds = totalDuration + withAnimation(.linear(duration: totalDuration)) { + timerProgress = 1.0 + } + } + .onChange(of: seconds, { + if seconds < 1 { + canDismiss = true + } + }) + #else ZStack { Circle() .stroke(advert.tint.opacity(0.2), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) @@ -29,9 +66,9 @@ struct BillboardCountdownView : View { .trim(from: 0, to: timerProgress) .stroke(advert.tint, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) - #if os(visionOS) + Text("\(seconds, specifier: "%.0f")") - .font(.compatibleSystem(.caption, design: .rounded, weight: .heavy)).monospacedDigit() + .font(.system(.caption, design: .rounded, weight: .heavy).monospacedDigit()) .rotationEffect(.degrees(90)) .minimumScaleFactor(0.5) .animation(.default, value: seconds) @@ -42,32 +79,6 @@ struct BillboardCountdownView : View { seconds -= 1 } } - #else - if #available(iOS 17.0, tvOS 17.0, *) { - Text("\(seconds, specifier: "%.0f")") - .font(.compatibleSystem(.caption, design: .rounded, weight: .heavy)).monospacedDigit() - .rotationEffect(.degrees(90)) - .minimumScaleFactor(0.5) - .animation(.default, value: seconds) - .transition(.identity) - .contentTransition(.numericText(value: seconds)) - .onReceive(timer) { _ in - if seconds > 0 { - seconds -= 1 - } - } - } else { - Text("\(seconds, specifier: "%.0f")") - .font(.compatibleSystem(.caption, design: .rounded, weight: .bold)).monospacedDigit() - .rotationEffect(.degrees(90)) - .minimumScaleFactor(0.5) - .onReceive(timer) { _ in - if seconds > 0 { - seconds -= 1 - } - } - } - #endif } #if os(visionOS) .foregroundStyle(.primary) @@ -82,16 +93,17 @@ struct BillboardCountdownView : View { timerProgress = 1.0 } } - .onChange(of: seconds) { _ in + .onChange(of: seconds, { if seconds < 1 { canDismiss = true } - } + }) .onTapGesture { #if DEBUG canDismiss = true #endif } + #endif } } diff --git a/Sources/Views/BillboardDismissButton.swift b/Sources/Views/BillboardDismissButton.swift new file mode 100644 index 0000000..e0b698b --- /dev/null +++ b/Sources/Views/BillboardDismissButton.swift @@ -0,0 +1,42 @@ +// +// BillboardDismissButton.swift +// +// +// Created by Hidde van der Ploeg on 01/07/2023. +// + +import SwiftUI + +struct BillboardDismissButton : View { + @Environment(\.dismiss) var dismiss + + var label: some View { +#if os(visionOS) + Label("Dismiss advertisement", systemImage: "xmark") + .labelStyle(.iconOnly) +#else + Label("Dismiss advertisement", systemImage: "xmark.circle.fill") + .labelStyle(.iconOnly) +#if os(tvOS) + .font(.system(.body, design: .rounded, weight: .bold)) +#else + .font(.system(.title2, design: .rounded, weight: .bold)) +#endif + .symbolRenderingMode(.hierarchical) + .imageScale(.large) +#endif + } + + var body: some View { + Button { + dismiss() + } label: { + label + } + #if os(tvOS) + .buttonBorderShape(.circle) + #else + .controlSize(.large) + #endif + } +} diff --git a/Sources/Billboard/Views/BillboardImageView.swift b/Sources/Views/BillboardImageView.swift similarity index 100% rename from Sources/Billboard/Views/BillboardImageView.swift rename to Sources/Views/BillboardImageView.swift diff --git a/Sources/Views/BillboardTextView.swift b/Sources/Views/BillboardTextView.swift new file mode 100644 index 0000000..4bf7906 --- /dev/null +++ b/Sources/Views/BillboardTextView.swift @@ -0,0 +1,47 @@ +// +// BillboardTextView.swift +// +// +// Created by Hidde van der Ploeg on 01/07/2023. +// + +import SwiftUI + +struct BillboardTextView : View { + let advert: BillboardAd + + var body: some View { + VStack(spacing: 10) { + BillboardAdInfoLabel(advert: advert) + + VStack(spacing: 6) { + Text(advert.title) + .font(.system(.title2, design: .rounded, weight: .heavy)) + .fixedSize(horizontal: false, vertical: true) + + Text(advert.description) + .font(.system(.body, design: .rounded)) +#if os(tvOS) + if let appStoreLink = advert.appStoreLink { + appStoreLink.qrCodeView + .padding(8) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.white)) + .frame(width: 200, height: 200) + .shadow(color: .black.opacity(0.2), radius: 6, x: 0, y: 3) + .shadow(color: .black.opacity(0.1), radius: 1, x: 0, y: 0) + .padding(.top, 40) + } +#endif + } + } + .multilineTextAlignment(.center) + .foregroundColor(advert.text) + .frame(maxWidth: 640) + .padding(.horizontal, 24) + .padding(.bottom, 64) + } +} + +#Preview { + BillboardTextView(advert: BillboardSamples.sampleDefaultAd) +} diff --git a/Sources/Billboard/Views/BillboardView.swift b/Sources/Views/BillboardView.swift similarity index 77% rename from Sources/Billboard/Views/BillboardView.swift rename to Sources/Views/BillboardView.swift index fa7dbea..1259ac2 100644 --- a/Sources/Billboard/Views/BillboardView.swift +++ b/Sources/Views/BillboardView.swift @@ -23,7 +23,7 @@ public struct BillboardView: View { } public var body: some View { - #if os(visionOS) +#if os(visionOS) NavigationStack { ZStack(alignment: .top) { advert.background.ignoresSafeArea() @@ -40,11 +40,11 @@ public struct BillboardView: View { if canDismiss { BillboardDismissButton() .onAppear { - #if os(iOS) +#if os(iOS) if config.allowHaptics { haptics(.light) } - #endif +#endif } } else { BillboardCountdownView(advert:advert, @@ -69,14 +69,14 @@ public struct BillboardView: View { .sheet(isPresented: $showPaywall) { paywall() } .onAppear(perform: displayOverlay) .onDisappear(perform: dismissOverlay) - .onChange(of: showPaywall) { newValue in - if newValue { + .onChange(of: showPaywall, { + if showPaywall { dismissOverlay() } else { displayOverlay() } - } - #else + }) +#else ZStack(alignment: .top) { advert.background.ignoresSafeArea() @@ -85,7 +85,7 @@ public struct BillboardView: View { } else { DefaultAdView(advert: advert) } - +#if !os(tvOS) HStack { Button { showPaywall.toggle() @@ -95,20 +95,18 @@ public struct BillboardView: View { .bold() } .buttonStyle(.bordered) - #if !os(tvOS) .controlSize(.small) - #endif Spacer() // TimerView if canDismiss { BillboardDismissButton() .onAppear { - #if os(iOS) +#if os(iOS) if config.allowHaptics { haptics(.light) } - #endif +#endif } } else { BillboardCountdownView(advert:advert, @@ -119,23 +117,51 @@ public struct BillboardView: View { .frame(height: 40) .tint(advert.tint) .padding() +#else + HStack { + // TimerView + if canDismiss { + BillboardDismissButton() + } else { + BillboardCountdownView(advert:advert, + totalDuration: config.duration, + canDismiss: $canDismiss) + } + + Spacer() + + + Button { + showPaywall.toggle() + } label: { + Text("Remove Ads") + .font(.system(.footnote, design: .rounded)) + .bold() + } + .buttonStyle(.bordered) + } + .frame(height: 40) + .tint(advert.tint) + .padding() +#endif } + .background(advert.background.ignoresSafeArea()) .sheet(isPresented: $showPaywall) { paywall() } #if !os(tvOS) .onAppear(perform: displayOverlay) .onDisappear(perform: dismissOverlay) - .onChange(of: showPaywall) { newValue in - if newValue { + .onChange(of: showPaywall, { + if showPaywall { dismissOverlay() } else { displayOverlay() } - } - + }) + .statusBarHidden(true) #endif - #endif - +#endif + } //MARK: - App Store Overlay @@ -163,12 +189,22 @@ public struct BillboardView: View { guard let scene else { return } storeOverlay.present(in: scene) - #if os(iOS) +#if os(iOS) if config.allowHaptics { haptics(.heavy) } - #endif +#endif } #endif } + +#Preview { + BillboardView(advert: BillboardSamples.sampleDefaultAd) { + VStack { + Spacer() + BillboardBannerView(advert: BillboardSamples.sampleDefaultAd) + Spacer() + } + } +} diff --git a/Sources/Billboard/Views/DefaultAdView.swift b/Sources/Views/DefaultAdView.swift similarity index 64% rename from Sources/Billboard/Views/DefaultAdView.swift rename to Sources/Views/DefaultAdView.swift index fdbfde8..06e4e2b 100644 --- a/Sources/Billboard/Views/DefaultAdView.swift +++ b/Sources/Views/DefaultAdView.swift @@ -11,37 +11,29 @@ struct DefaultAdView : View { let advert : BillboardAd var body: some View { - if #available(iOS 16, tvOS 17, *) { - ViewThatFits(in: .horizontal) { - HStack { - Spacer() - BillboardImageView(advert: advert) - - VStack { - Spacer() - BillboardTextView(advert: advert) - Spacer() - } - Spacer() - } + ViewThatFits(in: .horizontal) { + HStack { + Spacer() + BillboardImageView(advert: advert) VStack { Spacer() - BillboardImageView(advert: advert) BillboardTextView(advert: advert) Spacer() } - + Spacer() } - .background(backgroundView) - } else { + VStack { Spacer() BillboardImageView(advert: advert) BillboardTextView(advert: advert) Spacer() } - .background(backgroundView) + + } + .background { + backgroundView } } @@ -55,6 +47,9 @@ struct DefaultAdView : View { ZStack { advert.background .ignoresSafeArea() +#if os(visionOS) + .opacity(0.75) +#endif image .resizable() .opacity(0.1) @@ -65,8 +60,9 @@ struct DefaultAdView : View { default: - Color(hex: advert.backgroundColor) + advert.background .ignoresSafeArea() + } }) } @@ -75,9 +71,6 @@ struct DefaultAdView : View { } -struct DefaultAdView_Previews: PreviewProvider { - static var previews: some View { - DefaultAdView(advert: BillboardSamples.sampleDefaultAd) - } +#Preview { + DefaultAdView(advert: BillboardSamples.sampleDefaultAd) } - diff --git a/Sources/Billboard/Views/FullScreenAdView.swift b/Sources/Views/FullScreenAdView.swift similarity index 100% rename from Sources/Billboard/Views/FullScreenAdView.swift rename to Sources/Views/FullScreenAdView.swift diff --git a/Tests/BillboardTests/BillboardTests.swift b/Tests/BillboardTests/BillboardTests.swift index ad109fb..d06ef7f 100644 --- a/Tests/BillboardTests/BillboardTests.swift +++ b/Tests/BillboardTests/BillboardTests.swift @@ -2,20 +2,184 @@ import XCTest @testable import Billboard final class BillboardTests: XCTestCase { - - - func testFetchRandomAd() async throws { - guard let url = BillboardConfiguration().adsJSONURL else { - return + var sut: BillboardViewModel! + var configuration: BillboardConfiguration! + + override func setUp() { + super.setUp() + configuration = BillboardConfiguration( + adsJSONURL: URL(string: "https://billboard-source.vercel.app/ads.json"), + allowHaptics: true, + advertDuration: 15.0, + excludedIDs: [] + ) + sut = BillboardViewModel(configuration: configuration) + } + + override func tearDown() { + sut = nil + configuration = nil + super.tearDown() + } + + func testBillboardConfigurationInitialization() { + let customURL = URL(string: "https://example.com/ads.json") + let config = BillboardConfiguration( + adsJSONURL: customURL, + allowHaptics: false, + advertDuration: 10.0, + excludedIDs: ["123", "456"] + ) + + XCTAssertEqual(config.adsJSONURL, customURL) + XCTAssertFalse(config.allowHaptics) + XCTAssertEqual(config.duration, 10.0) + XCTAssertEqual(config.excludedIDs, ["123", "456"]) + } + + func testBillboardAdCreation() { + let ad = BillboardAd( + appStoreID: "123456789", + name: "TestApp", + title: "Test Title", + description: "Test Description", + category: .music, + media: URL(string: "https://example.com/image.jpg")!, + backgroundColor: "#000000", + textColor: "#FFFFFF", + tintColor: "#FF0000", + fullscreen: true, + transparent: false + ) + + XCTAssertEqual(ad.id, "TestApp+123456789") + XCTAssertEqual(ad.appStoreID, "123456789") + XCTAssertEqual(ad.name, "TestApp") + XCTAssertEqual(ad.title, "Test Title") + XCTAssertEqual(ad.description, "Test Description") + XCTAssertEqual(ad.category, .music) + XCTAssertEqual(ad.appStoreLink?.absoluteString, "https://apps.apple.com/app/id123456789") + XCTAssertTrue(ad.fullscreen) + XCTAssertFalse(ad.transparent) + } + + func testFetchRandomAdWithExcludedIDs() async throws { + // Create a mock URL that returns a known JSON response + let mockJSON = """ + { + "ads": [ + { + "appStoreID": "123", + "name": "App1", + "title": "Title1", + "description": "Description1", + "media": "https://example.com/1.jpg", + "backgroundColor": "#000000", + "textColor": "#FFFFFF", + "tintColor": "#FF0000", + "fullscreen": true, + "transparent": false + }, + { + "appStoreID": "456", + "name": "App2", + "title": "Title2", + "description": "Description2", + "media": "https://example.com/2.jpg", + "backgroundColor": "#000000", + "textColor": "#FFFFFF", + "tintColor": "#FF0000", + "fullscreen": false, + "transparent": true + } + ] } + """ + + // Create mock session + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [URLProtocolMock.self] + let mockSession = URLSession(configuration: config) + + // Setup mock data + let mockData = mockJSON.data(using: .utf8)! + URLProtocolMock.mockData = mockData + URLProtocolMock.mockResponse = HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 200, httpVersion: nil, headerFields: nil) + + // Create a test method that accepts a URLSession + func fetchRandomAdWithSession(from url: URL, excludedIDs: [String], session: URLSession) async throws -> BillboardAd? { + do { + let (data, _) = try await session.data(from: url) + let decoder = JSONDecoder() + let response = try decoder.decode(BillboardAdResponse.self, from: data) + let filteredAds = response.ads.filter({ !excludedIDs.contains($0.appStoreID) }) + return filteredAds.first + } catch { + throw error + } + } + + // Test fetching with excluded IDs + let excludedIDs = ["123"] + let mockURL = URL(string: "https://example.com/ads.json")! + let ad = try await fetchRandomAdWithSession(from: mockURL, excludedIDs: excludedIDs, session: mockSession) - let ad = try await BillboardViewModel.fetchRandomAd(from: url) XCTAssertNotNil(ad) + if let ad = ad { + XCTAssertFalse(excludedIDs.contains(ad.appStoreID)) + XCTAssertEqual(ad.appStoreID, "456") + } } +} + +// Mock URLProtocol for testing network requests +class URLProtocolMock: URLProtocol { + static var mockData: Data? + static var mockResponse: URLResponse? + static var mockError: Error? - func testShowAdvertisement() async throws { - let viewmodel = BillboardViewModel() - await viewmodel.showAdvertisement() - XCTAssertNotNil(viewmodel.advertisement) + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + if let error = URLProtocolMock.mockError { + client?.urlProtocol(self, didFailWithError: error) + return + } + + if let data = URLProtocolMock.mockData { + client?.urlProtocol(self, didLoad: data) + } + + if let response = URLProtocolMock.mockResponse { + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + } + + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +// Mock Response Extension +extension BillboardAd { + static func mockAd() -> BillboardAd { + return BillboardAd( + appStoreID: "123456789", + name: "TestApp", + title: "Test Title", + description: "Test Description", + media: URL(string: "https://example.com/image.jpg")!, + backgroundColor: "#000000", + textColor: "#FFFFFF", + tintColor: "#FF0000", + fullscreen: true, + transparent: false + ) } }