diff --git a/CHANGELOG.md b/CHANGELOG.md index 2551a21..f7a20da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added +- A `SubscriptionRevenueCatUI` product, the only one that links RevenueCatUI. It carries + `RevenueCatPaywallView`, which shows a paywall designed with RevenueCat Paywalls for an + offering (the current one for `nil`), and `PaywallContainer`, which shows either that or the + app's own paywall from one place. After a purchase or a restore the RevenueCat paywall asks the + `SubscriptionUseCase` whether the customer is entitled, rather than reading the `CustomerInfo` + RevenueCatUI hands back, so the configured entitlement decides and the `EntitlementCache` is + refreshed; a restore that finds nothing does not count as entitled. +- `PaywallMode` in `SubscriptionUI`: `.custom`, `.revenueCat(offering:)` and + `.automatic(offering:)`, with `.revenueCat` and `.automatic` as shorthands for the current + offering. A struct rather than an enum, because an enum case with an associated value and a + static property of the same name make `PaywallMode.automatic` ambiguous. `PaywallMode.choice(for:)` is the pure rule that turns the mode and a + `PaywallOfferingLookup` into a `PaywallChoice`; everything that stops a RevenueCat paywall from + being shown falls back to the app's own. Held in `SubscriptionUI` so that an app can keep the + setting without linking RevenueCatUI. +- `PaywallHandlers`: the `onEntitled` / `onError` / `onDismiss` that `PaywallContainer` passes + to the closure building the app's own paywall, so both paywalls report in the same three + callbacks. + +### Changed +- The minimum RevenueCat SDK is now 5.19.0, the first with `Offering.hasPaywall`. + ## [2.0.0] - 2026-09-08 ### Added diff --git a/Package.swift b/Package.swift index 7836094..ad61a05 100644 --- a/Package.swift +++ b/Package.swift @@ -20,11 +20,18 @@ let package = Package( .library( name: "SubscriptionUI", targets: ["SubscriptionUI"] + ), + // Paywalls built in the RevenueCat dashboard, and the container that chooses between one + // of those and the app's own. The only product that links RevenueCatUI, so an app that + // draws its own paywall does not carry it. + .library( + name: "SubscriptionRevenueCatUI", + targets: ["SubscriptionRevenueCatUI"] ) ], dependencies: [ // RevenueCat SDK - .package(url: "https://github.com/RevenueCat/purchases-ios.git", from: "5.14.0"), + .package(url: "https://github.com/RevenueCat/purchases-ios.git", from: "5.19.0"), .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.0") ], targets: [ @@ -42,6 +49,16 @@ let package = Package( dependencies: ["Subscription"], path: "Sources/SubscriptionUI" ), + .target( + name: "SubscriptionRevenueCatUI", + dependencies: [ + "Subscription", + "SubscriptionUI", + .product(name: "RevenueCat", package: "purchases-ios"), + .product(name: "RevenueCatUI", package: "purchases-ios") + ], + path: "Sources/SubscriptionRevenueCatUI" + ), .testTarget( name: "SubscriptionTests", dependencies: ["Subscription"], @@ -51,6 +68,14 @@ let package = Package( name: "SubscriptionUITests", dependencies: ["SubscriptionUI"], path: "Tests/SubscriptionUITests" + ), + .testTarget( + name: "SubscriptionRevenueCatUITests", + dependencies: [ + "SubscriptionRevenueCatUI", + .product(name: "RevenueCat", package: "purchases-ios") + ], + path: "Tests/SubscriptionRevenueCatUITests" ) ] ) diff --git a/README.ja.md b/README.ja.md index 1d1de1d..384a756 100644 --- a/README.ja.md +++ b/README.ja.md @@ -19,6 +19,8 @@ RevenueCat を使ったサブスクリプションとアプリ内課金を、小 どちらの答えかは値そのものが持つ(規約ではなく型で分かる) - 購入・復元・ユーザー同期 - 権利の変化を流す `AsyncStream`(アプリが起こしていない変化も届く) +- RevenueCat Paywalls で作ったペイウォールは別プロダクト `SubscriptionRevenueCatUI`。 + 自前のペイウォールとの切り替えを一か所で行える - ペイウォールの骨格は別プロダクト `SubscriptionUI`。請求される実額が常に主表示 - 全体が Sendable。状態は actor が持つ @@ -76,6 +78,55 @@ PaywallView( 復元ボタンと 2 本の法的リンクを持つ。**請求される実額が各行で最も大きい文字**で、 それを変える引数は無い —— 審査 3.1.2(c) を規約ではなく構造で守る。 +### RevenueCat のペイウォールに切り替える + +RevenueCatUI に依存するのは `SubscriptionRevenueCatUI` だけ。RevenueCat のダッシュボードで +作ったペイウォールを出し、`PaywallContainer` が `PaywallMode` に従って自前のペイウォールと +どちらを出すかを決める。 + +| `PaywallMode` | 出るもの | +|---|---| +| `.custom` | 常に自前。RevenueCat には何も問い合わせない | +| `.revenueCat(offering:)` | その offering の RevenueCat ペイウォール(`nil` なら current) | +| `.automatic(offering:)` | offering にダッシュボードでペイウォールが付いていれば RevenueCat、無ければ自前 | + +RevenueCat のペイウォールを出せない理由があるとき —— 環境に `SubscriptionUseCase` が無い、 +SDK が設定されていない、offering の読み込みに失敗した、指定した offering が無い —— は +自前のペイウォールを出す。 + +```swift +import SubscriptionRevenueCatUI +import SubscriptionUI + +PaywallContainer( + mode: .automatic, + onEntitled: { analytics.track(.purchaseCompleted) } +) { handlers in + NavigationStack { + PaywallView( + pages: pages, + links: links, + onEntitled: handlers.onEntitled, + onError: handlers.onError + ) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("閉じる", action: handlers.onDismiss) + } + } + } +} +``` + +コンテナはどちらのペイウォールにも枠(ナビゲーションバーや閉じるボタン)を付けない。 +RevenueCat のペイウォールは閉じるボタンを自分で描くので、ナビゲーションバーと閉じるボタンは +`custom` のクロージャの中に入れ、コンテナ自体はそのままシートに載せる。 +`onEntitled` / `onError` / `onDismiss` はどちらのペイウォールでも呼ばれる。自前のほうには +クロージャに渡る `PaywallHandlers` として届く。 + +`PaywallMode` と、それをどちらを出すかに変える規則(`PaywallMode.choice(for:)`)は +`SubscriptionUI` にある。RevenueCatUI をリンクしないアプリでも設定値として持てる。 + ## ドキュメント **[API ドキュメントと Getting Started](https://no-problem-dev.github.io/swift-subscription/documentation/subscription/)** @@ -88,7 +139,7 @@ Getting Started に、前提となる App Store Connect / RevenueCat ダッシ - iOS 17.0+ / macOS 14.0+ - Swift 6.0+ -- [RevenueCat SDK](https://github.com/RevenueCat/purchases-ios) 5.14.0+ +- [RevenueCat SDK](https://github.com/RevenueCat/purchases-ios) 5.19.0+ ## インストール diff --git a/README.md b/README.md index 8c2e9c8..9b101b9 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ answer current as renewals and expiries arrive. - An `AsyncStream` of entitlement changes, including ones the app did not cause - A separate `SubscriptionUI` product with a paywall skeleton that keeps the billed amount the most prominent price on screen +- A separate `SubscriptionRevenueCatUI` product that shows a paywall built with RevenueCat + Paywalls, and switches between it and the app's own from one place - Sendable throughout, with an actor holding the state ## Usage @@ -82,6 +84,54 @@ that will be billed is the largest text in every plan row and there is no parame change that, which is App Store Review 3.1.2(c) enforced by construction rather than by documentation. +### Switching to a RevenueCat paywall + +`SubscriptionRevenueCatUI` is the only product that links RevenueCatUI. It shows a paywall +designed in the RevenueCat dashboard, and `PaywallContainer` chooses between that and the app's +own paywall according to a `PaywallMode`: + +| `PaywallMode` | What is shown | +|---|---| +| `.custom` | Always the app's own paywall. RevenueCat is not asked anything | +| `.revenueCat(offering:)` | The RevenueCat paywall for the offering (`nil` for the current one) | +| `.automatic(offering:)` | The RevenueCat paywall if the offering has one attached in the dashboard, the app's own otherwise | + +Whatever stops a RevenueCat paywall from being shown — no `SubscriptionUseCase` in the +environment, the SDK not configured, the offerings failing to load, an offering that is not +there — ends at the app's own paywall. + +```swift +import SubscriptionRevenueCatUI +import SubscriptionUI + +PaywallContainer( + mode: .automatic, + onEntitled: { analytics.track(.purchaseCompleted) } +) { handlers in + NavigationStack { + PaywallView( + pages: pages, + links: links, + onEntitled: handlers.onEntitled, + onError: handlers.onError + ) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close", action: handlers.onDismiss) + } + } + } +} +``` + +The container adds no chrome to either paywall. The RevenueCat paywall draws its own close +button, so the navigation bar and the close button go inside the `custom` closure, and the +container is presented bare. `onEntitled`, `onError` and `onDismiss` are called for both +paywalls; the app's own gets them as the `PaywallHandlers` passed to the closure. + +`PaywallMode` and the rule that turns it into a choice (`PaywallMode.choice(for:)`) live in +`SubscriptionUI`, so an app can hold the setting without linking RevenueCatUI. + ## Documentation **[API documentation and Getting Started](https://no-problem-dev.github.io/swift-subscription/documentation/subscription/)** @@ -94,7 +144,7 @@ cannot be tested on a simulator. - iOS 17.0+ / macOS 14.0+ - Swift 6.0+ -- [RevenueCat SDK](https://github.com/RevenueCat/purchases-ios) 5.14.0+ +- [RevenueCat SDK](https://github.com/RevenueCat/purchases-ios) 5.19.0+ ## Installation diff --git a/Sources/SubscriptionRevenueCatUI/PaywallContainer.swift b/Sources/SubscriptionRevenueCatUI/PaywallContainer.swift new file mode 100644 index 0000000..988617c --- /dev/null +++ b/Sources/SubscriptionRevenueCatUI/PaywallContainer.swift @@ -0,0 +1,148 @@ +import RevenueCat +import Subscription +import SubscriptionUI +import SwiftUI + +/// The one place an app shows its paywall from, whichever paywall that turns out to be. +/// +/// ```swift +/// PaywallContainer( +/// mode: .automatic, +/// onEntitled: { analytics.track(.purchaseCompleted) } +/// ) { handlers in +/// NavigationStack { +/// PaywallView( +/// pages: pages, +/// links: links, +/// onEntitled: handlers.onEntitled, +/// onError: handlers.onError +/// ) +/// .toolbar { +/// ToolbarItem(placement: .cancellationAction) { +/// Button("Close", action: handlers.onDismiss) +/// } +/// } +/// } +/// } +/// ``` +/// +/// ## Chrome belongs to the custom paywall +/// +/// The two paywalls do not want the same frame around them. The app's paywall usually sits in a +/// `NavigationStack` with a title and a close button; a RevenueCat paywall draws its own close +/// button and runs edge to edge, and wrapping it in a navigation bar puts two close buttons on +/// screen. So the container adds no chrome to either, and the `custom` closure returns the app's +/// paywall with its chrome already on. Present the container itself bare, in a sheet. +/// +/// ## One set of callbacks +/// +/// `onEntitled`, `onError` and `onDismiss` are called for both paywalls. The RevenueCat one is +/// wired to them here; the app's own gets them as the ``PaywallHandlers`` passed to `custom`, +/// to hand on to `PaywallView` and to its close button. +/// +/// ## Falling back +/// +/// Which paywall is shown is ``PaywallMode/choice(for:)`` applied to what looking up the +/// offering found. Anything that stops the RevenueCat paywall from being shown — no +/// `SubscriptionUseCase` in the environment, the SDK not configured, the offerings failing to +/// load, an offering that is not there — ends at the app's own paywall. For ``PaywallMode/custom`` +/// nothing is looked up and the app's paywall appears without a loading state. +public struct PaywallContainer: View { + private enum Phase { + case deciding + case custom + case revenueCat(Offering) + } + + @Environment(\.subscriptionUseCase) private var subscriptionUseCase + @Environment(\.dismiss) private var dismiss + + private let mode: PaywallMode + private let onEntitled: (@MainActor () -> Void)? + private let onError: (@MainActor (any Error) -> Void)? + private let onDismiss: (@MainActor () -> Void)? + private let custom: (PaywallHandlers) -> Custom + + @State private var phase: Phase + + /// Creates a container. + /// + /// - Parameters: + /// - mode: Which paywall the app wants. Changing it decides again. + /// - onEntitled: Run when a purchase or a restore leaves the customer entitled, on either + /// paywall. + /// - onError: Run when a purchase or a restore fails, on either paywall. A cancelled + /// purchase does not arrive here, and neither does a failed lookup: that one falls back + /// to the app's paywall instead. + /// - onDismiss: Run when the customer asks to close either paywall. `nil` dismisses the + /// presentation the container is in. + /// - custom: Builds the app's own paywall, chrome included, from the handlers to call. + public init( + mode: PaywallMode, + onEntitled: (@MainActor () -> Void)? = nil, + onError: (@MainActor (any Error) -> Void)? = nil, + onDismiss: (@MainActor () -> Void)? = nil, + @ViewBuilder custom: @escaping (PaywallHandlers) -> Custom + ) { + self.mode = mode + self.onEntitled = onEntitled + self.onError = onError + self.onDismiss = onDismiss + self.custom = custom + self._phase = State(initialValue: mode.needsLookup ? .deciding : .custom) + } + + public var body: some View { + content + .task(id: mode) { await decide() } + } + + @ViewBuilder + private var content: some View { + switch phase { + case .deciding: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .custom: + custom(handlers) + case .revenueCat(let offering): + RevenueCatPaywallView(loaded: offering, displayCloseButton: true, handlers: handlers) + .id(offering.identifier) + } + } + + private var handlers: PaywallHandlers { + PaywallHandlers( + onEntitled: { onEntitled?() }, + onError: { onError?($0) }, + onDismiss: { if let onDismiss { onDismiss() } else { dismiss() } } + ) + } + + private func decide() async { + guard mode.needsLookup else { + phase = .custom + return + } + + var offering: Offering? + let lookup: PaywallOfferingLookup + if subscriptionUseCase == nil { + lookup = .unavailable + } else { + do { + offering = try await RevenueCatOfferingLookup.load(mode.offeringIdentifier) + lookup = RevenueCatOfferingLookup.lookup(for: offering) + } catch { + lookup = .unavailable + } + } + + switch (mode.choice(for: lookup), offering) { + case (.revenueCat, let offering?): + phase = .revenueCat(offering) + case (.revenueCat, nil), (.custom, _): + phase = .custom + } + } +} diff --git a/Sources/SubscriptionRevenueCatUI/RevenueCatOfferingLookup.swift b/Sources/SubscriptionRevenueCatUI/RevenueCatOfferingLookup.swift new file mode 100644 index 0000000..2b16497 --- /dev/null +++ b/Sources/SubscriptionRevenueCatUI/RevenueCatOfferingLookup.swift @@ -0,0 +1,46 @@ +import Foundation +import RevenueCat +import Subscription +import SubscriptionUI + +/// Finds the offering a paywall asked for and says whether it has a RevenueCat paywall. +/// +/// The SDK is never asked anything before `Purchases.isConfigured` is true. `Purchases.shared` +/// traps when nothing configured it, which is the state an app is in while it runs on a stub +/// `SubscriptionUseCase` or with an empty API key. +enum RevenueCatOfferingLookup { + /// The offering with this identifier, or the current one for `nil`. + static func offering( + _ identifier: String?, + all: [String: Offering], + current: Offering? + ) -> Offering? { + guard let identifier else { return current } + return all[identifier] + } + + /// What finding `offering` means for choosing a paywall. + static func lookup(for offering: Offering?) -> PaywallOfferingLookup { + guard let offering else { return .offeringMissing } + return offering.hasPaywall ? .hasPaywall : .noPaywall + } + + /// Loads the offering with this identifier, or the current one for `nil`. + /// + /// - Returns: The offering, or `nil` when the offerings loaded without it. + /// - Throws: `SubscriptionError.notConfigured` before the SDK is configured, or + /// `SubscriptionError.networkError(_:)` when the offerings cannot be fetched. + static func load(_ identifier: String?) async throws -> Offering? { + guard Purchases.isConfigured else { + throw SubscriptionError.notConfigured + } + + let offerings: Offerings + do { + offerings = try await Purchases.shared.offerings() + } catch { + throw SubscriptionError.networkError(error) + } + return offering(identifier, all: offerings.all, current: offerings.current) + } +} diff --git a/Sources/SubscriptionRevenueCatUI/RevenueCatPaywallView.swift b/Sources/SubscriptionRevenueCatUI/RevenueCatPaywallView.swift new file mode 100644 index 0000000..eb64c4f --- /dev/null +++ b/Sources/SubscriptionRevenueCatUI/RevenueCatPaywallView.swift @@ -0,0 +1,177 @@ +import RevenueCat +import RevenueCatUI +import Subscription +import SubscriptionUI +import SwiftUI + +/// No offering by the identifier a RevenueCat paywall asked for. +public enum RevenueCatPaywallError: Error, LocalizedError { + /// The offerings loaded without this one: an identifier that is not in the dashboard, or, + /// for `nil`, no offering marked current. + case offeringNotFound(String?) + + /// The message shown by `localizedDescription`. Hard-coded English, like + /// `SubscriptionError`'s; map the case to your own copy rather than displaying it. + public var errorDescription: String? { + switch self { + case .offeringNotFound(let identifier?): + return "No offering was found with the identifier \(identifier)." + case .offeringNotFound(nil): + return "No offering is marked current." + } + } +} + +/// A paywall designed in the RevenueCat dashboard with RevenueCat Paywalls, reporting in the +/// same three callbacks as `PaywallView`. +/// +/// ```swift +/// RevenueCatPaywallView( +/// offering: nil, +/// onEntitled: { dismiss() }, +/// onError: { presentedError = $0 } +/// ) +/// ``` +/// +/// RevenueCatUI buys and restores through `Purchases.shared` itself. What this view adds is the +/// answer to "is the customer entitled now?", which it takes from the `SubscriptionUseCase` in +/// the environment rather than from the `CustomerInfo` RevenueCatUI hands back: the use case +/// knows which entitlement means subscribed, and asking it writes the new reading to the +/// `EntitlementCache` too. A restore that finds nothing therefore does not call `onEntitled`. +/// +/// It draws its own chrome. Present it bare in a sheet — not inside a `NavigationStack` with a +/// close button of your own — and the dashboard's close button calls `onDismiss`. +/// +/// With no use case in the environment, or before the SDK is configured, it renders an error +/// and reports it to `onError`. `PaywallContainer` falls back to the app's own paywall in both +/// cases instead. +public struct RevenueCatPaywallView: View { + private enum Phase { + case loading + case loaded(Offering) + case failed(any Error) + } + + @Environment(\.subscriptionUseCase) private var subscriptionUseCase + @Environment(\.dismiss) private var dismiss + + private let offeringIdentifier: String? + private let displayCloseButton: Bool + private let onEntitled: (@MainActor () -> Void)? + private let onError: (@MainActor (any Error) -> Void)? + private let onDismiss: (@MainActor () -> Void)? + + @State private var phase: Phase + + /// Creates a paywall for an offering. + /// + /// - Parameters: + /// - offering: The offering's dashboard identifier, or `nil` for the current offering. + /// - displayCloseButton: Whether RevenueCatUI adds a close button to a template paywall. + /// A paywall built with the components editor carries the close button it was designed + /// with and ignores this. + /// - onEntitled: Run when a purchase or a restore leaves the customer entitled. + /// - onError: Run when the offering cannot be loaded, or a purchase or a restore fails. + /// A cancelled purchase does not arrive here. + /// - onDismiss: Run when the paywall's close button is tapped. `nil` dismisses the + /// presentation the paywall is in. + public init( + offering: String? = nil, + displayCloseButton: Bool = true, + onEntitled: (@MainActor () -> Void)? = nil, + onError: (@MainActor (any Error) -> Void)? = nil, + onDismiss: (@MainActor () -> Void)? = nil + ) { + self.offeringIdentifier = offering + self.displayCloseButton = displayCloseButton + self.onEntitled = onEntitled + self.onError = onError + self.onDismiss = onDismiss + self._phase = State(initialValue: .loading) + } + + init( + loaded offering: Offering, + displayCloseButton: Bool, + handlers: PaywallHandlers + ) { + self.offeringIdentifier = offering.identifier + self.displayCloseButton = displayCloseButton + self.onEntitled = handlers.onEntitled + self.onError = handlers.onError + self.onDismiss = handlers.onDismiss + self._phase = State(initialValue: .loaded(offering)) + } + + public var body: some View { + switch phase { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { await load() } + case .loaded(let offering): + PaywallView(offering: offering, displayCloseButton: displayCloseButton) + .onPurchaseCompleted { _ in + Task { await settle() } + } + .onRestoreCompleted { _ in + Task { await settle() } + } + .onPurchaseFailure { error in + onError?(SubscriptionError.purchaseFailed(error)) + } + .onRestoreFailure { error in + onError?(SubscriptionError.restoreFailed(error)) + } + .onRequestedDismissal { + if let onDismiss { onDismiss() } else { dismiss() } + } + case .failed(let error): + ContentUnavailableView( + "Paywall unavailable", + systemImage: "exclamationmark.triangle", + description: Text(error.localizedDescription) + ) + } + } + + private func load() async { + guard subscriptionUseCase != nil else { + fail(SubscriptionError.notConfigured) + return + } + + do { + guard let offering = try await RevenueCatOfferingLookup.load(offeringIdentifier) else { + fail(RevenueCatPaywallError.offeringNotFound(offeringIdentifier)) + return + } + phase = .loaded(offering) + } catch { + fail(error) + } + } + + private func fail(_ error: any Error) { + phase = .failed(error) + onError?(error) + } + + /// Asks the use case rather than reading the `CustomerInfo` RevenueCatUI passed, because + /// only the use case knows which entitlement counts. A restore that finds nothing succeeds, + /// and branching on the success alone would tell someone who never subscribed that they had + /// been restored. + private func settle() async { + guard let subscriptionUseCase else { + onError?(SubscriptionError.notConfigured) + return + } + + do { + let status = try await subscriptionUseCase.checkSubscriptionStatus() + if status.isActive { onEntitled?() } + } catch { + onError?(error) + } + } +} diff --git a/Sources/SubscriptionUI/Documentation.docc/SubscriptionUI.md b/Sources/SubscriptionUI/Documentation.docc/SubscriptionUI.md index 187d686..a0ab2a0 100644 --- a/Sources/SubscriptionUI/Documentation.docc/SubscriptionUI.md +++ b/Sources/SubscriptionUI/Documentation.docc/SubscriptionUI.md @@ -71,6 +71,17 @@ through something of its own. ``PaywallPlanPicker``, ``PaywallPlanRow`` and ``PaywallLegalFooter`` are public for the same reason: an app that draws its own paywall can still take the parts that are not design decisions. +### Choosing between this paywall and a RevenueCat one + +``PaywallMode`` says which paywall the app wants: its own, one built with RevenueCat Paywalls, +or the RevenueCat one only when the offering has one attached in the dashboard. +``PaywallMode/choice(for:)`` turns that and what the lookup of the offering found into a +``PaywallChoice``, and anything that stops the RevenueCat paywall from being shown ends at the +app's own. The setting lives here so that an app can hold it without linking RevenueCatUI; +showing the RevenueCat paywall, and the container that switches between the two, are in the +`SubscriptionRevenueCatUI` product. That container hands the app's paywall a +``PaywallHandlers`` so both report in the same three callbacks. + ## Topics ### The paywall @@ -89,6 +100,13 @@ still take the parts that are not design decisions. - ``PaywallLegalLinks`` - ``PaywallLabels`` +### Switching paywalls + +- ``PaywallMode`` +- ``PaywallOfferingLookup`` +- ``PaywallChoice`` +- ``PaywallHandlers`` + ### Previews - ``PreviewSubscriptionUseCase`` diff --git a/Sources/SubscriptionUI/PaywallHandlers.swift b/Sources/SubscriptionUI/PaywallHandlers.swift new file mode 100644 index 0000000..0e5a391 --- /dev/null +++ b/Sources/SubscriptionUI/PaywallHandlers.swift @@ -0,0 +1,55 @@ +import Foundation + +/// The three things a paywall reports, whichever paywall it is. +/// +/// `PaywallContainer` hands one of these to the closure that builds the app's own paywall, and +/// wires the RevenueCat paywall to the same three. Pass them through to ``PaywallView`` and to +/// the close button, and the app hears about a purchase, a failure or a dismissal in the same +/// place whichever paywall the customer saw. +/// +/// ```swift +/// PaywallContainer(mode: .automatic, onEntitled: { … }) { handlers in +/// NavigationStack { +/// PaywallView( +/// pages: pages, +/// links: links, +/// onEntitled: handlers.onEntitled, +/// onError: handlers.onError +/// ) +/// .toolbar { +/// ToolbarItem(placement: .cancellationAction) { +/// Button("Close", action: handlers.onDismiss) +/// } +/// } +/// } +/// } +/// ``` +public struct PaywallHandlers { + /// Call when a purchase or a restore leaves the customer entitled. + public let onEntitled: @MainActor () -> Void + + /// Call when a purchase or a restore fails. + /// + /// Not for a cancelled purchase: dismissing the store's sheet is an ordinary outcome, and + /// ``PaywallView`` does not report it either. + public let onError: @MainActor (any Error) -> Void + + /// Call when the customer asks to close the paywall without buying. + public let onDismiss: @MainActor () -> Void + + /// Creates a set of handlers. + /// + /// - Parameters: + /// - onEntitled: Run when a purchase or a restore leaves the customer entitled. + /// - onError: Run when a purchase or a restore fails. + /// - onDismiss: Run when the customer asks to close the paywall. + public init( + onEntitled: @escaping @MainActor () -> Void, + onError: @escaping @MainActor (any Error) -> Void, + onDismiss: @escaping @MainActor () -> Void + ) { + self.onEntitled = onEntitled + self.onError = onError + self.onDismiss = onDismiss + } +} diff --git a/Sources/SubscriptionUI/PaywallMode.swift b/Sources/SubscriptionUI/PaywallMode.swift new file mode 100644 index 0000000..c83b3de --- /dev/null +++ b/Sources/SubscriptionUI/PaywallMode.swift @@ -0,0 +1,126 @@ +import Foundation + +/// Which paywall an app wants to show: the one it draws itself, or one built in the RevenueCat +/// dashboard with RevenueCat Paywalls. +/// +/// The mode is a wish, not the outcome. What is actually shown is decided by +/// ``choice(for:)`` once the offering has been looked up, and anything that goes wrong on the +/// RevenueCat side ends at the app's own paywall rather than at an empty sheet. +/// +/// A struct rather than an enum so that `PaywallMode.automatic` and `.revenueCat` can name the +/// current offering: an enum case with an associated value and a static property of the same +/// name make `PaywallMode.automatic` ambiguous. +/// +/// This type lives here, and not beside the RevenueCat paywall, so that an app can hold and +/// switch the setting without linking RevenueCatUI. Showing a RevenueCat paywall needs the +/// `SubscriptionRevenueCatUI` product. +public struct PaywallMode: Sendable, Hashable { + private enum Kind: Sendable, Hashable { + case custom + case revenueCat + case automatic + } + + private let kind: Kind + private let offering: String? + + private init(_ kind: Kind, offering: String?) { + self.kind = kind + self.offering = offering + } + + /// Always the app's own paywall. RevenueCat is not asked anything. + public static let custom = PaywallMode(.custom, offering: nil) + + /// The RevenueCat paywall for the current offering. + public static let revenueCat = PaywallMode(.revenueCat, offering: nil) + + /// The RevenueCat paywall for the current offering if it has one, the app's own otherwise. + public static let automatic = PaywallMode(.automatic, offering: nil) + + /// The RevenueCat paywall for an offering, `nil` meaning the current one. + /// + /// Shown even when the offering has no paywall attached in the dashboard, in which case + /// RevenueCatUI draws its default one from the offering's packages. Only an offering that + /// cannot be found or loaded falls back to the app's own paywall. + public static func revenueCat(offering: String?) -> PaywallMode { + PaywallMode(.revenueCat, offering: offering) + } + + /// The RevenueCat paywall when the offering has one attached in the dashboard, and the + /// app's own paywall otherwise. + /// + /// The setting to ship with when the dashboard is where paywalls are designed but not every + /// offering has one yet: attaching a paywall switches it on without an app release, and + /// detaching it switches it off. + public static func automatic(offering: String?) -> PaywallMode { + PaywallMode(.automatic, offering: offering) + } + + /// The offering to look up, or `nil` for the current one. + /// + /// Also `nil` for ``custom``, which looks nothing up; ask ``needsLookup`` to tell the two + /// apart. + public var offeringIdentifier: String? { + kind == .custom ? nil : offering + } + + /// Whether ``choice(for:)`` depends on anything RevenueCat knows. + /// + /// `false` only for ``custom``, which shows the app's paywall straight away, without a + /// loading state and without a network round trip. + public var needsLookup: Bool { + kind != .custom + } + + /// The paywall to show, given what the lookup of the offering found. + /// + /// | mode | ``PaywallOfferingLookup/hasPaywall`` | ``PaywallOfferingLookup/noPaywall`` | ``PaywallOfferingLookup/offeringMissing`` | ``PaywallOfferingLookup/unavailable`` | + /// |---|---|---|---|---| + /// | ``custom`` | custom | custom | custom | custom | + /// | ``revenueCat(offering:)`` | RevenueCat | RevenueCat | custom | custom | + /// | ``automatic(offering:)`` | RevenueCat | custom | custom | custom | + /// + /// - Parameter lookup: What looking up ``offeringIdentifier`` found. + /// - Returns: The paywall to show. + public func choice(for lookup: PaywallOfferingLookup) -> PaywallChoice { + switch (kind, lookup) { + case (.custom, _): + return .custom + case (_, .offeringMissing), (_, .unavailable): + return .custom + case (.revenueCat, .hasPaywall), (.revenueCat, .noPaywall): + return .revenueCat + case (.automatic, .hasPaywall): + return .revenueCat + case (.automatic, .noPaywall): + return .custom + } + } +} + +/// What looking up an offering in RevenueCat found, as far as choosing a paywall is concerned. +public enum PaywallOfferingLookup: Sendable, Hashable { + /// The offering exists and has a paywall attached in the dashboard. + case hasPaywall + + /// The offering exists and has no paywall attached. + case noPaywall + + /// The offerings loaded, but not the one asked for: an identifier that is not in the + /// dashboard, or no offering marked current. + case offeringMissing + + /// RevenueCat could not answer: the SDK is not configured, no `SubscriptionUseCase` is in + /// the environment, or the offerings failed to load. + case unavailable +} + +/// The paywall that ``PaywallMode/choice(for:)`` settled on. +public enum PaywallChoice: Sendable, Hashable { + /// The app's own paywall. + case custom + + /// The paywall built with RevenueCat Paywalls. + case revenueCat +} diff --git a/Tests/SubscriptionRevenueCatUITests/RevenueCatOfferingLookupTests.swift b/Tests/SubscriptionRevenueCatUITests/RevenueCatOfferingLookupTests.swift new file mode 100644 index 0000000..8cb7f3f --- /dev/null +++ b/Tests/SubscriptionRevenueCatUITests/RevenueCatOfferingLookupTests.swift @@ -0,0 +1,78 @@ +import Foundation +import RevenueCat +import SubscriptionUI +import Testing +@testable import SubscriptionRevenueCatUI + +@Suite("RevenueCat の offering を探す") +struct RevenueCatOfferingLookupTests { + private static func offering(_ identifier: String, paywall: PaywallData? = nil) -> Offering { + Offering( + identifier: identifier, + serverDescription: identifier, + paywall: paywall, + availablePackages: [], + webCheckoutUrl: nil + ) + } + + private static let paywall = PaywallData( + templateName: "1", + config: PaywallData.Configuration( + packages: ["$rc_annual"], + images: PaywallData.Configuration.Images(), + colors: PaywallData.Configuration.ColorInformation( + light: PaywallData.Configuration.Colors( + background: "#FFFFFF", + text1: "#000000", + callToActionBackground: "#000000", + callToActionForeground: "#FFFFFF" + ) + ) + ), + localization: PaywallData.LocalizedConfiguration(title: "Title", callToAction: "Continue"), + assetBaseURL: URL(string: "https://example.com")! + ) + + @Test("指定が無ければ current") + func currentWhenUnspecified() { + let current = Self.offering("default") + let found = RevenueCatOfferingLookup.offering( + nil, + all: ["default": current, "summer": Self.offering("summer")], + current: current + ) + #expect(found?.identifier == "default") + } + + @Test("指定があればその offering で、current ではない") + func namedOffering() { + let current = Self.offering("default") + let found = RevenueCatOfferingLookup.offering( + "summer", + all: ["default": current, "summer": Self.offering("summer")], + current: current + ) + #expect(found?.identifier == "summer") + } + + @Test("指定した offering がダッシュボードに無ければ、current で代用しない") + func missingNamedOffering() { + let current = Self.offering("default") + let found = RevenueCatOfferingLookup.offering("winter", all: ["default": current], current: current) + #expect(found == nil) + #expect(RevenueCatOfferingLookup.lookup(for: found) == .offeringMissing) + } + + @Test("current が無いときは offering が無い扱い") + func noCurrent() { + let found = RevenueCatOfferingLookup.offering(nil, all: [:], current: nil) + #expect(RevenueCatOfferingLookup.lookup(for: found) == .offeringMissing) + } + + @Test("ペイウォールが付いているかは Offering.hasPaywall で判定する") + func hasPaywall() { + #expect(RevenueCatOfferingLookup.lookup(for: Self.offering("plain")) == .noPaywall) + #expect(RevenueCatOfferingLookup.lookup(for: Self.offering("designed", paywall: Self.paywall)) == .hasPaywall) + } +} diff --git a/Tests/SubscriptionUITests/PaywallModeTests.swift b/Tests/SubscriptionUITests/PaywallModeTests.swift new file mode 100644 index 0000000..22b43de --- /dev/null +++ b/Tests/SubscriptionUITests/PaywallModeTests.swift @@ -0,0 +1,81 @@ +import Testing +import SubscriptionUI + +@Suite("どちらのペイウォールを出すか") +struct PaywallModeTests { + private static let lookups: [PaywallOfferingLookup] = [ + .hasPaywall, .noPaywall, .offeringMissing, .unavailable + ] + + @Test("自前を選んだら、RevenueCat の答えによらず自前", arguments: lookups) + func customAlwaysCustom(_ lookup: PaywallOfferingLookup) { + #expect(PaywallMode.custom.choice(for: lookup) == .custom) + } + + @Test("RevenueCat を選んだら、offering があれば RevenueCat を出す", arguments: [ + PaywallOfferingLookup.hasPaywall, .noPaywall + ]) + func revenueCatWhenOfferingExists(_ lookup: PaywallOfferingLookup) { + #expect(PaywallMode.revenueCat.choice(for: lookup) == .revenueCat) + #expect(PaywallMode.revenueCat(offering: "summer").choice(for: lookup) == .revenueCat) + } + + @Test("RevenueCat を選んでも、offering が無いか読み込めなければ自前に戻る", arguments: [ + PaywallOfferingLookup.offeringMissing, .unavailable + ]) + func revenueCatFallsBack(_ lookup: PaywallOfferingLookup) { + #expect(PaywallMode.revenueCat.choice(for: lookup) == .custom) + #expect(PaywallMode.revenueCat(offering: "summer").choice(for: lookup) == .custom) + } + + @Test("自動では、offering にペイウォールが付いているときだけ RevenueCat") + func automaticNeedsAPaywall() { + #expect(PaywallMode.automatic.choice(for: .hasPaywall) == .revenueCat) + #expect(PaywallMode.automatic(offering: "summer").choice(for: .hasPaywall) == .revenueCat) + } + + @Test("自動では、ペイウォールが付いていない・offering が無い・読み込めないときは自前", arguments: [ + PaywallOfferingLookup.noPaywall, .offeringMissing, .unavailable + ]) + func automaticFallsBack(_ lookup: PaywallOfferingLookup) { + #expect(PaywallMode.automatic.choice(for: lookup) == .custom) + } + + @Test("自前のときだけ RevenueCat に問い合わせない") + func onlyCustomSkipsTheLookup() { + #expect(!PaywallMode.custom.needsLookup) + #expect(PaywallMode.revenueCat.needsLookup) + #expect(PaywallMode.automatic(offering: "summer").needsLookup) + } + + @Test("offering の指定が無ければ current を探す") + func offeringIdentifier() { + #expect(PaywallMode.custom.offeringIdentifier == nil) + #expect(PaywallMode.revenueCat.offeringIdentifier == nil) + #expect(PaywallMode.automatic.offeringIdentifier == nil) + #expect(PaywallMode.revenueCat(offering: "summer").offeringIdentifier == "summer") + #expect(PaywallMode.automatic(offering: "summer").offeringIdentifier == "summer") + } + + private static let automaticByName = PaywallMode.automatic + private static let revenueCatByName = PaywallMode.revenueCat + + @Test("型名から書いた PaywallMode.automatic と .revenueCat は current の offering") + func shorthandsByTypeName() { + #expect(Self.automaticByName == .automatic(offering: nil)) + #expect(Self.automaticByName.offeringIdentifier == nil) + #expect(Self.automaticByName.choice(for: .noPaywall) == .custom) + #expect(Self.revenueCatByName == .revenueCat(offering: nil)) + #expect(Self.revenueCatByName.offeringIdentifier == nil) + #expect(Self.revenueCatByName.choice(for: .noPaywall) == .revenueCat) + } + + @Test("型を明示した代入の .automatic と .revenueCat は current の offering") + func shorthandsByContextualType() { + let automatic: PaywallMode = .automatic + let revenueCat: PaywallMode = .revenueCat + #expect(automatic == .automatic(offering: nil)) + #expect(revenueCat == .revenueCat(offering: nil)) + #expect(automatic != revenueCat) + } +}