feat(commerce): integrate RevenueCat entitlements and paywalls

This commit is contained in:
Kaede Akino committed 2026-10-01 12:28:53 +08:00
1 parent 22f09eaeee
commit cfaceb5d15
27 files changed
+856 -1568

No files matched your search

@@ -1,168 +0,0 @@
import Foundation
import TermindDomain
import TermindPresentation
import Testing
struct ProOfferPresentationTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [1, 2, 4])
func storeKitWeeklyTrialsDisplayTheirFullNumberOfDays(language: String, weeks: Int) throws {
var fixture = OfferFixture()
fixture.trialDuration = DateComponents(weekOfYear: weeks)
let weekly = fixture.presentation(locale: Locale(identifier: language))
#expect(try #require(weekly.trialBadge).contains(String(weeks * 7)))
#expect(try #require(weekly.selectedPlanDetail).contains(String(weeks * 7)))
fixture.trialDuration = DateComponents(day: weeks * 7)
let daily = fixture.presentation(locale: Locale(identifier: language))
#expect(weekly.trialBadge == daily.trialBadge)
#expect(weekly.selectedPlanDetail == daily.selectedPlanDetail)
fixture.canStartTrial = false
fixture.trialDuration = DateComponents(weekOfYear: weeks)
#expect(fixture.presentation().trialBadge == nil)
}
@Test(arguments: [ProPurchasePlan.annual, .lifetime], [false, true])
func purchaseTitleFollowsPlanAndTrialEligibility(plan: ProPurchasePlan, eligible: Bool) {
var fixture = OfferFixture()
fixture.plan = plan
fixture.canStartTrial = eligible
let expected: String.LocalizationValue =
switch plan {
case .annual: eligible ? "Start Free Trial" : "Subscribe Annually"
case .lifetime: "Buy Lifetime Access"
}
#expect(fixture.presentation().purchaseButtonTitle == String(localized: expected))
}
@Test(arguments: [false, true], [false, true])
func annualDetailsRequireBothPriceAndBillingPeriod(hasPrice: Bool, hasPeriod: Bool) {
var fixture = OfferFixture()
fixture.annualPrice = hasPrice ? "$12.34" : nil
fixture.billingPeriod = hasPeriod ? DateComponents(year: 1) : nil
let offer = fixture.presentation()
#expect((offer.selectedPlanDetail != nil) == (hasPrice && hasPeriod))
if let detail = offer.selectedPlanDetail { #expect(detail.contains("$12.34")) }
}
@Test
func ineligibleTrialDataDoesNotAffectTheOffer() {
var fixture = OfferFixture()
fixture.canStartTrial = false
let withDuration = fixture.presentation()
fixture.trialDuration = nil
#expect(withDuration == fixture.presentation())
#expect(withDuration.trialBadge == nil)
}
@Test
func missingTrialDurationKeepsTheEligibleTitleWithoutInventingABadge() {
var fixture = OfferFixture()
let complete = fixture.presentation()
fixture.trialDuration = nil
let missingDuration = fixture.presentation()
fixture.canStartTrial = false
let paid = fixture.presentation()
#expect(missingDuration.purchaseButtonTitle == complete.purchaseButtonTitle)
#expect(missingDuration.purchaseButtonTitle != paid.purchaseButtonTitle)
#expect(missingDuration.trialBadge == nil)
#expect(missingDuration.selectedPlanDetail == paid.selectedPlanDetail)
}
@Test(arguments: [Calendar.Component.year, .month, .weekOfYear, .day])
func singularPeriodsUseAUnitNameAndPreserveUnitPriority(component: Calendar.Component) {
var fixture = OfferFixture()
var period = DateComponents()
period.setValue(1, for: component)
fixture.billingPeriod = period
let key: String.LocalizationValue =
switch component {
case .year: "year"
case .month: "month"
case .weekOfYear: "week"
default: "day"
}
#expect(fixture.presentation().annualBillingUnit == String(localized: key))
if component == .year {
fixture.billingPeriod?.month = 1
#expect(fixture.presentation().annualBillingUnit == String(localized: "year"))
}
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func compositePeriodsKeepOneFullUnitInTheEffectiveLocale(identifier: String) throws {
var fixture = OfferFixture()
let period = DateComponents(month: 3, day: 10)
fixture.billingPeriod = period
let locale = Locale(identifier: identifier)
let formatter = DateComponentsFormatter()
var calendar = Calendar(identifier: .gregorian)
calendar.locale = locale
formatter.calendar = calendar
formatter.unitsStyle = .full
formatter.maximumUnitCount = 1
let expected = try #require(formatter.string(from: period))
let offer = fixture.presentation(locale: locale)
#expect(offer.annualBillingUnit == expected)
#expect(offer.selectedPlanDetail?.contains(expected) == true)
#expect(offer.selectedPlanDetail?.contains("$12.34") == true)
}
@Test
func nativeDefaultLookupRemainsIndependentOfTheDurationFormattingLocale() {
let fixture = OfferFixture()
let english = fixture.presentation(locale: Locale(identifier: "en"))
let chinese = fixture.presentation(locale: Locale(identifier: "zh-Hant"))
#expect(english.purchaseButtonTitle == chinese.purchaseButtonTitle)
#expect(english.annualBillingUnit == chinese.annualBillingUnit)
#expect(english.trialBadge != chinese.trialBadge)
}
@Test
func callerLocalizationAppliesToEveryOfferLabel() throws {
var fixture = OfferFixture()
let offer = fixture.presentation { "[custom]" + String(localized: $0) }
let labels = [
offer.purchaseButtonTitle, try #require(offer.annualBillingUnit),
try #require(offer.trialBadge), try #require(offer.selectedPlanDetail),
]
#expect(labels.allSatisfy { $0.hasPrefix("[custom]") })
fixture.plan = .lifetime
let lifetime = fixture.presentation { "[custom]" + String(localized: $0) }
#expect(lifetime.purchaseButtonTitle.hasPrefix("[custom]"))
#expect(lifetime.selectedPlanDetail?.hasPrefix("[custom]") == true)
}
@Test
func lifetimeDetailsIgnoreAnnualProductAvailabilityWhileTheAnnualBadgeRemainsIndependent() {
var fixture = OfferFixture()
fixture.plan = .lifetime
let complete = fixture.presentation()
#expect(complete.trialBadge != nil)
fixture.annualPrice = nil
fixture.billingPeriod = nil
fixture.trialDuration = nil
let missing = fixture.presentation()
#expect(missing.purchaseButtonTitle == complete.purchaseButtonTitle)
#expect(missing.selectedPlanDetail == complete.selectedPlanDetail)
#expect(missing.selectedPlanDetail != nil)
#expect(missing.trialBadge == nil)
}
}
private struct OfferFixture {
var plan: ProPurchasePlan = .annual
var annualPrice: String? = "$12.34"
var billingPeriod: DateComponents? = DateComponents(year: 1)
var trialDuration: DateComponents? = DateComponents(day: 7)
var canStartTrial = true
func presentation(
locale: Locale = Locale(identifier: "en"),
localize: (String.LocalizationValue) -> String = { String(localized: $0) }
) -> ProOfferPresentation {
ProOfferPresentation(
plan: plan, annualPrice: annualPrice, billingPeriod: billingPeriod,
trialDuration: trialDuration, canStartTrial: canStartTrial, locale: locale, localize: localize)
}
}
@@ -1,98 +0,0 @@
import Foundation
import TermindDomain
import TermindPresentation
import Testing
struct ProPaywallPresentationTests {
@Test(arguments: [
(ProSubscriptionPhase.introductoryTrial, "Free Trial", "Trial Ends"),
(.subscribed, "Annual Subscription", "Renews"),
(.gracePeriod, "Billing Grace Period", "Grace Period Ends"),
(.billingRetry, "Billing Issue", "Expired"),
(.expired, "Subscription Expired", "Expired"),
(.revoked, "Subscription Revoked", "Expired"),
])
func subscriptionKeepsItsStatusAndDateMeaning(phase: ProSubscriptionPhase, title: String, dateLabel: String) {
let status = subscription(phase: phase)
let access = ProAccessPresentation(status: status)
#expect(access.title.key == title)
#expect(access.dateLabel?.key == dateLabel)
#expect(access.date == .distantFuture)
#expect(ProPaywallPresentation(status: status).canManageSubscription)
}
@Test(arguments: [ProOwnership.purchased, .familyShared])
func canceledRenewalKeepsAccessAndShowsTheAccessEnd(ownership: ProOwnership) {
let status = subscription(phase: .subscribed, ownership: ownership, autoRenew: false)
let access = ProAccessPresentation(status: status)
#expect(access.dateLabel?.key == AppStrings.Settings.accessEnds.key)
#expect(status.hasProAccess)
#expect(!ProPaywallPresentation(status: status).showsPurchaseActions)
#expect(ProPaywallPresentation(status: status).canManageSubscription == (ownership == .purchased))
}
@Test(arguments: [
ProSubscriptionPhase.introductoryTrial, .subscribed, .gracePeriod, .billingRetry, .expired, .revoked,
])
func sharedSubscriptionsIdentifyTheOwnerWithoutOfferingManagement(phase: ProSubscriptionPhase) {
let status = subscription(phase: phase, ownership: .familyShared)
#expect(ProAccessPresentation(status: status).isFamilyShared)
#expect(!ProPaywallPresentation(status: status).canManageSubscription)
}
@Test(arguments: [
(EntitlementStatus.loading, "Checking…"),
(.free(trialAvailable: true), "Free"),
(.purchased(.purchased), "Lifetime Access"),
(.purchased(.familyShared), "Lifetime Access · Family Shared"),
(.verificationFailed, "The App Store could not verify this purchase."),
(.unrestrictedForTesting, "Development Access"),
])
func nonSubscriptionStatesNeverShowSubscriptionDatesOrManagement(status: EntitlementStatus, title: String) {
let access = ProAccessPresentation(status: status)
#expect(access.title.key == title)
#expect(access.date == nil)
#expect(access.dateLabel == nil)
#expect(!ProPaywallPresentation(status: status).canManageSubscription)
}
@Test
func missingPricesAndProcessingPreventPurchasesWithoutHidingRecovery() {
var presentation = ProPaywallPresentation(
status: .free(trialAvailable: false), lifetimePrice: "$49.99",
canPurchaseAnnual: true, canPurchaseLifetime: true)
#expect(presentation.needsProductReload)
#expect(!presentation.canPurchase(.annual))
#expect(presentation.canPurchase(.lifetime))
presentation.annualPrice = "$19.99"
#expect(!presentation.needsProductReload)
#expect(presentation.canPurchase(.annual))
presentation.canPurchaseAnnual = false
#expect(!presentation.canPurchase(.annual))
#expect(presentation.canPurchase(.lifetime))
presentation.isProcessing = true
#expect(!presentation.canPurchase(.lifetime))
#expect(presentation.showsPurchaseActions)
}
@Test
func activeAccessStillShowsRestoreProgressAndErrors() {
var presentation = ProPaywallPresentation(status: .purchased(.purchased))
#expect(!presentation.showsPurchaseActions)
presentation.isProcessing = true
#expect(presentation.showsPurchaseActions)
presentation.isProcessing = false
presentation.message = "Restore failed"
#expect(presentation.showsPurchaseActions)
#expect(!presentation.canPurchase(.annual))
#expect(!presentation.canPurchase(.lifetime))
}
private func subscription(
phase: ProSubscriptionPhase, ownership: ProOwnership = .purchased, autoRenew: Bool = true
) -> EntitlementStatus {
.subscription(
ProSubscriptionStatus(
phase: phase, expiresAt: .distantFuture, willAutoRenew: autoRenew, ownership: ownership))
}
}
+61
View File
@@ -0,0 +1,61 @@
import Foundation
import TermindDomain
import TermindPresentation
import Testing
struct ProPaywallStateTests {
@Test(arguments: [ProOwnership.purchased, .familyShared])
func lifetimeAccessHasNoRenewalDateOrManagement(ownership: ProOwnership) {
let state = ProPaywallState(status: .purchased(ownership))
#expect(state.hasProAccess)
#expect(state.state == "lifetime")
#expect(state.expiresAt == nil)
#expect(!state.canManageSubscription)
#expect(state.isFamilyShared == (ownership == .familyShared))
}
@Test(arguments: [true, false, nil] as [Bool?], [ProOwnership.purchased, .familyShared])
func renewalStatePreservesAccessAndOwnership(autoRenew: Bool?, ownership: ProOwnership) {
let state = ProPaywallState(
status: .subscription(
.init(
phase: .subscribed, expiresAt: .distantFuture, willAutoRenew: autoRenew, ownership: ownership)))
#expect(state.hasProAccess)
#expect(state.expiresAt == .distantFuture)
#expect(state.state == (autoRenew == true ? "renewing" : autoRenew == false ? "ending" : "active"))
#expect(state.canManageSubscription == (ownership == .purchased))
#expect(state.isFamilyShared == (ownership == .familyShared))
}
@Test(arguments: [
(ProSubscriptionPhase.introductoryTrial, "trial", true),
(.gracePeriod, "grace", true),
(.billingRetry, "retry", false),
(.expired, "expired", false),
(.revoked, "revoked", false),
])
func subscriptionPhaseSelectsTheCorrectRemoteVariant(phase: ProSubscriptionPhase, code: String, active: Bool) {
let state = ProPaywallState(
status: .subscription(
.init(
phase: phase, expiresAt: .distantFuture, willAutoRenew: nil, ownership: .purchased)))
#expect(state.state == code)
#expect(state.hasProAccess == active)
#expect(state.expiresAt == .distantFuture)
#expect(state.canManageSubscription)
}
@Test
func missingExpirationDoesNotInventADateAndUnverifiedAccessDoesNotUnlock() {
let trial = ProPaywallState(
status: .subscription(
.init(
phase: .introductoryTrial, expiresAt: nil, willAutoRenew: nil, ownership: .familyShared)))
#expect(trial.hasProAccess)
#expect(trial.expiresAt == nil)
#expect(!trial.canManageSubscription)
let unverified = ProPaywallState(status: .verificationFailed)
#expect(!unverified.hasProAccess)
#expect(unverified.state == "unverified")
}
}
@@ -0,0 +1,239 @@
import Foundation
import RevenueCat
import TermindDomain
import Testing
@testable import TermindInfrastructure
@Suite @MainActor
struct RevenueCatCommerceTests {
private let now = Date(timeIntervalSince1970: 1_800_000_000)
@Test
func testStoreKeyCannotEnableProductionAndWhitespaceIsTrimmed() throws {
#expect(
RevenueCatConfiguration(apiKey: "test_example", entitlementID: "termind_pro", allowsTestStore: false) == nil
)
let test = try #require(
RevenueCatConfiguration(apiKey: " test_example \n", entitlementID: "termind_pro", allowsTestStore: true))
#expect(test.isTestStore)
#expect(test.apiKey == "test_example")
#expect(
RevenueCatConfiguration(
apiKey: "$(REVENUECAT_API_KEY)", entitlementID: "termind_pro", allowsTestStore: true) == nil)
#expect(
RevenueCatConfiguration(apiKey: "sk_private", entitlementID: "termind_pro", allowsTestStore: true) == nil)
#expect(RevenueCatConfiguration(apiKey: "appl_example", entitlementID: "", allowsTestStore: false) == nil)
#expect(
RevenueCatConfiguration(apiKey: "appl_example", entitlementID: "termind_pro", allowsTestStore: false) != nil
)
}
@Test
func onlyTheConfiguredEntitlementUnlocksPro() throws {
let info = try customerInfo(entitlementID: "another_entitlement", expiration: now.addingTimeInterval(60))
#expect(resolve(info) == .free(trialAvailable: false))
}
@Test
func lifetimeAndTestStoreProductIDsAreResolvedThroughTheEntitlement() throws {
let info = try customerInfo(expiration: nil)
#expect(resolve(info) == .purchased(.purchased))
}
@Test
func cancelledSubscriptionRetainsAccessUntilExpiryAndFamilyOwnership() throws {
let info = try customerInfo(
expiration: now.addingTimeInterval(60), period: "trial", ownership: "FAMILY_SHARED", cancelled: true)
guard case .subscription(let status) = resolve(info) else {
Issue.record("Missing subscription")
return
}
#expect(status.phase == .introductoryTrial)
#expect(status.ownership == .familyShared)
#expect(status.willAutoRenew == false)
#expect(status.expiresAt == now.addingTimeInterval(60))
}
@Test
func cachedActiveFlagCannotExtendAnExpiredSubscription() throws {
let info = try customerInfo(expiration: now.addingTimeInterval(60))
#expect(info.entitlements.all["termind_pro"]?.isActive == true)
#expect(
!RevenueCatEntitlementResolver.resolve(info, entitlementID: "termind_pro", now: now.addingTimeInterval(61))
.hasProAccess)
}
@Test
func graceUsesItsOwnDeadlineAndBillingRetryDoesNotUnlock() throws {
let graceEnd = now.addingTimeInterval(120)
let info = try customerInfo(expiration: now.addingTimeInterval(-60), billingIssue: true, grace: graceEnd)
guard case .subscription(let status) = resolve(info) else {
Issue.record("Missing grace")
return
}
#expect(status.phase == .gracePeriod)
#expect(status.expiresAt == graceEnd)
let retry = try customerInfo(expiration: now.addingTimeInterval(-60), billingIssue: true)
#expect(!resolve(retry).hasProAccess)
#expect(!RevenueCatEntitlementResolver.resolve(info, entitlementID: "termind_pro", now: graceEnd).hasProAccess)
}
@Test
func refundsAndFailedVerificationCannotUnlock() throws {
#expect(!resolve(try customerInfo(expiration: now.addingTimeInterval(60), refunded: true)).hasProAccess)
#expect(resolve(try customerInfo(expiration: nil, verification: 2)) == .verificationFailed)
}
@Test
func restoreUsesRevenueCatAndKeepsTheAccountVariantOpen() async {
let service = FakeService()
let controller = ProAccessController(purchaseService: service, observeUpdates: false)
controller.isPaywallPresented = true
await controller.restorePurchases()
#expect(service.restores == 1)
#expect(controller.isPaywallPresented)
#expect(controller.error == .purchaseNotFound)
service.status = .purchased(.purchased)
await controller.restorePurchases()
#expect(controller.hasProAccess)
#expect(controller.isPaywallPresented)
#expect(controller.error == nil)
}
@Test
func remotePaywallCompletionUpdatesAccessWithoutClosingItsAccountVariant() async {
let service = FakeService()
let controller = ProAccessController(purchaseService: service, observeUpdates: false)
controller.isPaywallPresented = true
controller.beginPaywallOperation()
service.status = .purchased(.purchased)
await controller.completePaywallOperation()
#expect(controller.hasProAccess)
#expect(controller.isPaywallPresented)
#expect(!controller.isProcessing)
service.status = .free(trialAvailable: false)
await controller.refresh()
#expect(!controller.hasProAccess)
#expect(controller.isPaywallPresented)
}
@Test
func customerInfoUpdatesGrantAndRevokeAccessWithoutAStoreKitFallback() async {
let service = FakeService()
let controller = ProAccessController(purchaseService: service)
await controller.refresh()
service.continuation.yield(.purchased(.purchased))
for _ in 0..<100 where !controller.hasProAccess { await Task.yield() }
#expect(controller.hasProAccess)
service.continuation.yield(.free(trialAvailable: false))
for _ in 0..<100 where controller.hasProAccess { await Task.yield() }
#expect(!controller.hasProAccess)
}
@Test
func newerCustomerInfoWinsOverAnOlderRefreshResponse() async {
let service = FakeService()
let controller = ProAccessController(purchaseService: service)
await controller.refresh()
service.suspendsRefresh = true
let refresh = Task { await controller.refresh() }
for _ in 0..<100 where service.refreshContinuation == nil { await Task.yield() }
#expect(service.refreshContinuation != nil)
service.continuation.yield(.purchased(.purchased))
for _ in 0..<100 where !controller.hasProAccess { await Task.yield() }
#expect(controller.hasProAccess)
service.refreshContinuation?.resume(returning: .free(trialAvailable: false))
service.refreshContinuation = nil
await refresh.value
#expect(controller.hasProAccess)
}
@Test
func anUnavailableServiceDoesNotStartAStoreKitFallback() async {
let controller = ProAccessController(purchaseService: nil)
await controller.refresh()
#expect(!controller.hasProAccess)
#expect(!controller.usesRevenueCat)
#expect(controller.status == .verificationFailed)
await controller.restorePurchases()
#expect(controller.error == .productUnavailable)
}
@Test
func aFailedInitialFetchDoesNotLeaveThePaywallLoading() async {
let service = FakeService()
service.refreshFails = true
let controller = ProAccessController(purchaseService: service, observeUpdates: false)
await controller.refresh()
#expect(controller.status == .verificationFailed)
#expect(controller.error != nil)
service.refreshFails = false
service.status = .purchased(.purchased)
await controller.refresh()
#expect(controller.hasProAccess)
#expect(controller.error == nil)
}
private func resolve(_ info: CustomerInfo) -> EntitlementStatus {
RevenueCatEntitlementResolver.resolve(info, entitlementID: "termind_pro", now: now)
}
private func customerInfo(
entitlementID: String = "termind_pro", expiration: Date?, period: String = "normal",
ownership: String = "PURCHASED", cancelled: Bool = false, billingIssue: Bool = false,
grace: Date? = nil, refunded: Bool = false, verification: Int = 1
) throws -> CustomerInfo {
let formatter = ISO8601DateFormatter()
let purchase = formatter.string(from: now.addingTimeInterval(-3600))
let productID = expiration == nil ? "test_lifetime" : "test_annual"
var product: [String: Any] = [
"purchase_date": purchase, "original_purchase_date": purchase, "store": "app_store",
"is_sandbox": true, "period_type": period, "ownership_type": ownership,
]
product["expires_date"] = expiration.map { formatter.string(from: $0) }
product["unsubscribe_detected_at"] = cancelled ? purchase : nil
product["billing_issues_detected_at"] = billingIssue ? purchase : nil
product["grace_period_expires_date"] = grace.map { formatter.string(from: $0) }
product["refunded_at"] = refunded ? purchase : nil
var entitlement: [String: Any] = ["product_identifier": productID, "purchase_date": purchase]
entitlement["expires_date"] = (grace ?? expiration).map { formatter.string(from: $0) }
let data: [String: Any] = [
"request_date": formatter.string(from: now), "entitlementVerification": verification,
"subscriber": [
"original_app_user_id": "$RCAnonymousID:test", "first_seen": purchase,
"subscriptions": expiration == nil ? [:] : [productID: product],
"non_subscriptions": expiration == nil ? [productID: [product]] : [:],
"entitlements": [entitlementID: entitlement],
],
]
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(CustomerInfo.self, from: JSONSerialization.data(withJSONObject: data))
}
private final class FakeService: ProPurchaseService {
let updates: AsyncStream<EntitlementStatus>
let continuation: AsyncStream<EntitlementStatus>.Continuation
var status: EntitlementStatus = .free(trialAvailable: false)
var restores = 0
var refreshFails = false
var suspendsRefresh = false
var refreshContinuation: CheckedContinuation<EntitlementStatus, Never>?
init() { (updates, continuation) = AsyncStream.makeStream() }
func currentStatus() async throws -> EntitlementStatus {
if refreshFails { throw URLError(.notConnectedToInternet) }
if suspendsRefresh {
return await withCheckedContinuation { refreshContinuation = $0 }
}
return status
}
func restore() async throws -> EntitlementStatus {
restores += 1
return status
}
}
}
+19 -19
View File
@@ -10273,6 +10273,25 @@
}
}
},
"Drag files to upload on terminal" : {
"comment" : "A feature of Termind Pro.",
"extractionState" : "manual",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "拖放文件到终端上传"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "拖放檔案到終端上傳"
}
}
}
},
"Drag to Import" : {
"comment" : "Title of a drop target presentation for importing a private key or certificate.",
"extractionState" : "manual",
@@ -32668,25 +32687,6 @@
}
}
},
"Upload files by dropping them on a terminal" : {
"comment" : "A feature of Termind Pro.",
"extractionState" : "manual",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "将文件拖到终端以上传"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "將檔案拖到終端以上傳"
}
}
}
},
"Upload to %@" : {
"extractionState" : "manual",
"localizations" : {
@@ -1,114 +1,73 @@
import Foundation
import StoreKit
import TermindDomain
@MainActor
@Observable
public final class ProAccessController {
public static let lifetimeProductID = "com.akinokaede.termind.pro"
public static let annualProductID = "com.akinokaede.termind.pro.annual"
private let purchaseService: (any ProPurchaseService)?
private let now: () -> Date
private var transactionUpdates: Task<Void, Never>?
private var refreshTask: Task<Void, Never>?
private var accessTask: Task<EntitlementStatus, Never>?
private var metadataTask: Task<Void, Never>?
private var productsTask: Task<[Product], any Error>?
private var customerInfoUpdates: Task<Void, Never>?
private var initialRefreshTask: Task<Void, Never>?
private var accessTask: Task<Result<EntitlementStatus, ProAccessControllerError>, Never>?
private var entitlementExpirationTask: Task<Void, Never>?
private var refreshGeneration: UInt64 = 0
private var metadataError: ProAccessControllerError?
public private(set) var status: EntitlementStatus
/// The permanent, non-consumable product. Kept under the existing name for source compatibility.
public private(set) var product: Product?
public private(set) var annualProduct: Product?
public private(set) var isEligibleForAnnualIntroOffer: Bool?
public private(set) var error: ProAccessControllerError?
public private(set) var isProcessing = false
public private(set) var pendingProductID: String?
public var isPaywallPresented = false
public var requestedFeature: ProFeature?
public var displayPrice: String? { product?.displayPrice }
public var annualDisplayPrice: String? { annualProduct?.displayPrice }
public var annualBillingPeriod: DateComponents? {
annualProduct?.subscription.map { Self.dateComponents(for: $0.subscriptionPeriod, count: 1) }
}
public var annualIntroOfferDuration: DateComponents? {
guard let offer = Self.freeIntroductoryOffer(for: annualProduct) else { return nil }
return Self.dateComponents(for: offer.period, count: offer.periodCount)
}
public var usesRevenueCat: Bool { purchaseService != nil }
public var hasProAccess: Bool { status.hasProAccess }
public var canPurchase: Bool { canPurchase(.lifetime) }
public var canPurchaseAnnual: Bool { canPurchase(.annual) }
public var canStartTrial: Bool {
canPurchase(.annual) && isEligibleForAnnualIntroOffer == true
&& Self.freeIntroductoryOffer(for: annualProduct) != nil
}
public var message: String? {
if let error { return error.localizedDescription }
if pendingProductID != nil { return ProAccessControllerError.purchasePending.localizedDescription }
if status.canPurchase, let metadataError { return metadataError.localizedDescription }
return nil
}
public static func live() -> ProAccessController { ProAccessController() }
public static func live() -> ProAccessController {
let service = RevenueCatConfiguration.bundled().map { RevenueCatProPurchaseService(configuration: $0) }
return ProAccessController(purchaseService: service)
}
public static func unrestrictedForTesting() -> ProAccessController {
testing(status: .unrestrictedForTesting)
}
public static func testing(status: EntitlementStatus) -> ProAccessController {
let controller = ProAccessController(observeStore: false)
let controller = ProAccessController(purchaseService: nil, observeUpdates: false)
controller.status = status
return controller
}
public init(
init(
purchaseService: (any ProPurchaseService)?,
now: @escaping () -> Date = { .now },
observeStore: Bool = true
observeUpdates: Bool = true
) {
self.purchaseService = purchaseService
self.now = now
status = .loading
guard observeStore else { return }
transactionUpdates = Task { [weak self] in
for await update in Transaction.updates {
status = purchaseService == nil ? .verificationFailed : .loading
guard observeUpdates, let purchaseService else { return }
let updates = purchaseService.updates
customerInfoUpdates = Task { [weak self] in
for await status in updates {
guard !Task.isCancelled, let self else { return }
await self.handleTransactionUpdate(update)
self.acceptServiceStatus(status)
}
}
refreshTask = Task { [weak self] in await self?.refresh() }
metadataTask = Task { [weak self] in await self?.reloadProducts() }
initialRefreshTask = Task { [weak self] in await self?.refresh() }
}
isolated deinit {
transactionUpdates?.cancel()
refreshTask?.cancel()
customerInfoUpdates?.cancel()
initialRefreshTask?.cancel()
accessTask?.cancel()
metadataTask?.cancel()
productsTask?.cancel()
entitlementExpirationTask?.cancel()
}
public func canUse(_: ProFeature) -> Bool {
status.hasProAccess
}
public func canPurchase(_ plan: ProPurchasePlan) -> Bool {
guard status.canPurchase, !isProcessing else { return false }
switch plan {
case .annual:
return pendingProductID != Self.annualProductID && annualProduct != nil
case .lifetime:
return pendingProductID != Self.lifetimeProductID && product != nil
}
}
public func canUse(_: ProFeature) -> Bool { hasProAccess }
@discardableResult
public func require(_ feature: ProFeature) -> Bool {
guard !canUse(feature) else { return true }
requestedFeature = feature
isPaywallPresented = true
presentPaywall(for: feature)
return false
}
@@ -117,101 +76,54 @@ public final class ProAccessController {
isPaywallPresented = true
}
/// StoreKit's local transactions resolve access independently of network product metadata.
public func refresh() async { await refresh(including: nil) }
private func refresh(including transaction: Transaction?) async {
if status == .unrestrictedForTesting { return }
if accessTask == nil || transaction != nil {
accessTask?.cancel()
/// RevenueCat owns purchases, receipt synchronization, cached access, and subscription updates.
public func refresh() async {
guard status != .unrestrictedForTesting else { return }
guard let purchaseService else {
error = .productUnavailable
apply(.verificationFailed)
return
}
if accessTask == nil {
refreshGeneration &+= 1
accessTask = Task { [weak self] in
await self?.currentStatus(including: transaction) ?? .loading
accessTask = Task {
do {
return .success(try await purchaseService.currentStatus())
} catch {
return .failure(.operationFailed(error.localizedDescription))
}
}
}
let generation = refreshGeneration
guard let task = accessTask else { return }
let resolved = await task.value
guard generation == refreshGeneration else {
if accessTask != nil { await refresh() }
return
}
guard accessTask != nil else { return }
let result = await task.value
// A newer CustomerInfo update takes precedence over an earlier request.
guard generation == refreshGeneration, accessTask != nil else { return }
accessTask = nil
switch resolved {
case .purchased:
pendingProductID = nil
case .subscription(let subscription) where subscription.phase.hasProAccess:
if pendingProductID == Self.annualProductID { pendingProductID = nil }
default:
break
}
apply(resolved)
}
/// Purchases the annual subscription. StoreKit applies its introductory offer for eligible accounts.
public func purchaseAnnual() async { await purchaseProduct(Self.annualProductID) }
public func purchaseLifetime() async { await purchaseProduct(Self.lifetimeProductID) }
private func purchaseProduct(_ id: String) async {
guard !isProcessing, pendingProductID != id, status.canPurchase else { return }
isProcessing = true
error = nil
defer { isProcessing = false }
await refresh()
guard status.canPurchase else { return }
do {
try await fetchProducts()
let selected: Product
switch id {
case Self.annualProductID:
guard let annualProduct else { throw ProAccessControllerError.productUnavailable }
selected = annualProduct
case Self.lifetimeProductID:
guard let product else { throw ProAccessControllerError.productUnavailable }
selected = product
default:
throw ProAccessControllerError.productUnavailable
}
switch try await selected.purchase() {
case .success(let verification):
guard case .verified(let transaction) = verification, transaction.productID == id else {
error = .purchaseVerificationFailed
return
}
await refresh(including: transaction)
await transaction.finish()
if status.hasProAccess { isPaywallPresented = false }
case .pending:
pendingProductID = id
case .userCancelled:
break
@unknown default:
error = .purchaseVerificationFailed
}
} catch StoreKitError.userCancelled {
// Cancellation is not an entitlement change or a failure to display.
} catch let purchaseError {
error = purchaseError as? ProAccessControllerError ?? .operationFailed(purchaseError.localizedDescription)
switch result {
case .success(let resolved):
error = nil
apply(resolved)
case .failure(let failure):
error = failure
if status == .loading { apply(.verificationFailed) }
}
}
public func restorePurchases() async {
guard !isProcessing else { return }
isProcessing = true
error = nil
guard let purchaseService else {
error = .productUnavailable
return
}
beginPaywallOperation()
defer { isProcessing = false }
do {
try await AppStore.sync()
await refresh()
switch status {
case .purchased:
isPaywallPresented = false
case .subscription(let subscription) where subscription.phase.hasProAccess:
isPaywallPresented = false
case .verificationFailed:
let restored = try await purchaseService.restore()
acceptServiceStatus(restored)
if restored == .verificationFailed {
error = .purchaseVerificationFailed
default:
} else if !restored.hasProAccess {
error = .purchaseNotFound
}
} catch {
@@ -219,217 +131,33 @@ public final class ProAccessController {
}
}
public func reloadProducts() async {
do {
try await fetchProducts()
} catch {
product = nil
annualProduct = nil
isEligibleForAnnualIntroOffer = nil
metadataError = .productMetadataUnavailable
}
private func acceptServiceStatus(_ next: EntitlementStatus) {
refreshGeneration &+= 1
accessTask?.cancel()
accessTask = nil
error = nil
if next.hasProAccess { isProcessing = false }
apply(next)
}
private func fetchProducts() async throws {
let task =
productsTask
?? Task {
try await Product.products(for: [Self.lifetimeProductID, Self.annualProductID])
}
productsTask = task
defer { productsTask = nil }
let loaded = try await task.value
product = loaded.first { Self.isLifetimeProduct($0) }
annualProduct = loaded.first { Self.isAnnualSubscription($0) }
if let subscription = annualProduct?.subscription,
Self.freeIntroductoryOffer(for: annualProduct) != nil
{
isEligibleForAnnualIntroOffer = await subscription.isEligibleForIntroOffer
} else {
isEligibleForAnnualIntroOffer = false
}
if product == nil, annualProduct == nil {
metadataError = .productUnavailable
} else {
metadataError = nil
}
if case .free = status {
apply(.free(trialAvailable: isEligibleForAnnualIntroOffer == true))
}
public func beginPaywallOperation() {
isProcessing = true
error = nil
}
public static func isLifetimeProduct(_ product: Product) -> Bool {
product.id == lifetimeProductID && product.type == .nonConsumable && product.isFamilyShareable
}
public func cancelPaywallOperation() { isProcessing = false }
public static func isAnnualSubscription(_ product: Product) -> Bool {
guard product.id == annualProductID, product.type == .autoRenewable,
product.isFamilyShareable, let subscription = product.subscription
else { return false }
return subscription.subscriptionPeriod.unit == .year && subscription.subscriptionPeriod.value == 1
}
public static func freeIntroductoryOffer(for product: Product?) -> Product.SubscriptionOffer? {
guard let offer = product?.subscription?.introductoryOffer,
offer.type == .introductory, offer.paymentMode == .freeTrial, offer.price == .zero
else { return nil }
return offer
}
private func currentStatus(including update: Transaction?) async -> EntitlementStatus {
let lifetimeResult = await currentLifetimeTransaction(including: update)
if let lifetime = lifetimeResult.transaction {
return .purchased(Self.ownership(of: lifetime))
}
var verificationFailed = lifetimeResult.verificationFailed
let annualResult: VerificationResult<Transaction>?
if let update, update.productID == Self.annualProductID {
annualResult = .verified(update)
} else {
annualResult = await Transaction.latest(for: Self.annualProductID)
}
if let annualResult {
switch annualResult {
case .verified(let transaction):
return .subscription(await subscriptionStatus(for: transaction))
case .unverified:
verificationFailed = true
}
}
return verificationFailed ? .verificationFailed : .free(trialAvailable: isEligibleForAnnualIntroOffer == true)
}
private func currentLifetimeTransaction(
including update: Transaction?
) async -> (transaction: Transaction?, verificationFailed: Bool) {
if let update, update.productID == Self.lifetimeProductID, update.productType == .nonConsumable,
update.revocationDate == nil
{
return (update, false)
}
var verificationFailed = false
for await result in Transaction.currentEntitlements {
switch result {
case .verified(let transaction)
where
transaction.productID == Self.lifetimeProductID && transaction.revocationDate == nil
&& transaction.productType == .nonConsumable
&& !(update?.productID == Self.lifetimeProductID && update?.revocationDate != nil
&& update?.originalID == transaction.originalID):
return (transaction, false)
case .unverified(let transaction, _) where transaction.productID == Self.lifetimeProductID:
verificationFailed = true
default:
break
}
}
if let latest = await Transaction.latest(for: Self.lifetimeProductID) {
switch latest {
case .verified(let transaction)
where transaction.revocationDate == nil && transaction.productType == .nonConsumable
&& !(update?.productID == Self.lifetimeProductID && update?.revocationDate != nil
&& update?.originalID == transaction.originalID):
return (transaction, false)
case .unverified:
verificationFailed = true
default:
break
}
}
return (nil, verificationFailed)
}
private func subscriptionStatus(for transaction: Transaction) async -> ProSubscriptionStatus {
let ownership = Self.ownership(of: transaction)
if transaction.revocationDate != nil || transaction.productType != .autoRenewable {
return ProSubscriptionStatus(
phase: .revoked, expiresAt: transaction.expirationDate, willAutoRenew: false,
ownership: ownership)
}
let storeStatus = await transaction.subscriptionStatus
let renewalInfo: Product.SubscriptionInfo.RenewalInfo?
if let storeStatus, case .verified(let verified) = storeStatus.renewalInfo {
renewalInfo = verified
} else {
renewalInfo = nil
}
if let expiration = transaction.expirationDate, expiration > now(), !transaction.isUpgraded {
let phase: ProSubscriptionPhase = Self.isIntroductoryTrial(transaction) ? .introductoryTrial : .subscribed
return ProSubscriptionStatus(
phase: phase, expiresAt: expiration, willAutoRenew: renewalInfo?.willAutoRenew,
ownership: ownership)
}
switch storeStatus?.state {
case .inGracePeriod:
if let graceExpiration = renewalInfo?.gracePeriodExpirationDate, graceExpiration > now() {
return ProSubscriptionStatus(
phase: .gracePeriod, expiresAt: graceExpiration, willAutoRenew: renewalInfo?.willAutoRenew,
ownership: ownership)
}
return ProSubscriptionStatus(
phase: .billingRetry, expiresAt: transaction.expirationDate,
willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership)
case .inBillingRetryPeriod:
return ProSubscriptionStatus(
phase: .billingRetry, expiresAt: transaction.expirationDate,
willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership)
case .revoked:
return ProSubscriptionStatus(
phase: .revoked, expiresAt: transaction.expirationDate, willAutoRenew: false,
ownership: ownership)
case .subscribed:
return ProSubscriptionStatus(
phase: .subscribed, expiresAt: transaction.expirationDate,
willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership)
case .expired, .none:
return ProSubscriptionStatus(
phase: .expired, expiresAt: transaction.expirationDate, willAutoRenew: false,
ownership: ownership)
default:
return ProSubscriptionStatus(
phase: .expired, expiresAt: transaction.expirationDate, willAutoRenew: false,
ownership: ownership)
}
}
private static func isIntroductoryTrial(_ transaction: Transaction) -> Bool {
transaction.offer?.type == .introductory && transaction.offer?.paymentMode == .freeTrial
}
private static func ownership(of transaction: Transaction) -> ProOwnership {
transaction.ownershipType == .familyShared ? .familyShared : .purchased
}
public func handleTransactionUpdate(_ update: VerificationResult<Transaction>) async {
let knownProductIDs = [Self.lifetimeProductID, Self.annualProductID]
guard knownProductIDs.contains(update.unsafePayloadValue.productID) else { return }
if case .verified(let transaction) = update {
error = nil
if pendingProductID == transaction.productID { pendingProductID = nil }
await refresh(including: transaction)
await transaction.finish()
} else {
error = .purchaseVerificationFailed
await refresh()
}
public func completePaywallOperation() async {
isProcessing = false
await refresh()
}
private func apply(_ next: EntitlementStatus) {
status = next
entitlementExpirationTask?.cancel()
let expiresAt: Date?
switch next {
case .subscription(let subscription) where subscription.phase.hasProAccess:
expiresAt = subscription.expiresAt
default:
expiresAt = nil
}
if let expiresAt {
if case .subscription(let subscription) = next,
subscription.phase.hasProAccess, let expiresAt = subscription.expiresAt, expiresAt > now()
{
entitlementExpirationTask = Task { [weak self] in
let remaining = max(0, expiresAt.timeIntervalSince(self?.now() ?? .now))
try? await Task.sleep(for: .seconds(remaining))
@@ -438,27 +166,11 @@ public final class ProAccessController {
}
}
}
private static func dateComponents(for period: Product.SubscriptionPeriod, count: Int) -> DateComponents {
let value = period.value * count
var components = DateComponents()
switch period.unit {
case .day: components.day = value
case .week: components.weekOfYear = value
case .month: components.month = value
case .year: components.year = value
@unknown default: break
}
return components
}
}
public nonisolated enum ProAccessControllerError: LocalizedError, Equatable, Sendable {
case productUnavailable
case productMetadataUnavailable
case purchaseVerificationFailed
case purchasePending
case operationFailed(String)
case purchaseNotFound
@@ -466,14 +178,10 @@ public nonisolated enum ProAccessControllerError: LocalizedError, Equatable, Sen
switch self {
case .productUnavailable:
String(localized: "Termind Pro is not available from the App Store right now.")
case .productMetadataUnavailable:
String(localized: "The App Store is temporarily unavailable. You can keep using already unlocked features.")
case .operationFailed(let description):
description
case .purchaseVerificationFailed:
String(localized: "The App Store could not verify this purchase.")
case .purchasePending:
String(localized: "The purchase is waiting for App Store approval.")
case .purchaseNotFound:
String(localized: "No active Termind Pro purchase or subscription was found for this App Store account.")
}
@@ -0,0 +1,8 @@
import TermindDomain
@MainActor
protocol ProPurchaseService: AnyObject {
var updates: AsyncStream<EntitlementStatus> { get }
func currentStatus() async throws -> EntitlementStatus
func restore() async throws -> EntitlementStatus
}
@@ -0,0 +1,32 @@
import Foundation
nonisolated struct RevenueCatConfiguration: Equatable, Sendable {
let apiKey: String
let entitlementID: String
var isTestStore: Bool { apiKey.hasPrefix("test_") }
init?(apiKey: String?, entitlementID: String?, allowsTestStore: Bool) {
let key = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let entitlement = entitlementID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !entitlement.isEmpty, !entitlement.contains("$("),
key.hasPrefix("appl_") || (allowsTestStore && key.hasPrefix("test_")),
let suffix = key.split(separator: "_", maxSplits: 1).last,
!suffix.isEmpty, key.count > 5,
key.rangeOfCharacter(from: .whitespacesAndNewlines) == nil
else { return nil }
self.apiKey = key
self.entitlementID = entitlement
}
static func bundled(in bundle: Bundle = .main) -> RevenueCatConfiguration? {
#if DEBUG
let allowsTestStore = true
#else
let allowsTestStore = false
#endif
return RevenueCatConfiguration(
apiKey: bundle.object(forInfoDictionaryKey: "TermindRevenueCatAPIKey") as? String,
entitlementID: bundle.object(forInfoDictionaryKey: "TermindRevenueCatEntitlementID") as? String,
allowsTestStore: allowsTestStore)
}
}
@@ -0,0 +1,37 @@
import Foundation
import RevenueCat
import TermindDomain
nonisolated enum RevenueCatEntitlementResolver {
static func resolve(_ info: CustomerInfo, entitlementID: String, now: Date) -> EntitlementStatus {
guard info.entitlements.verification != .failed else { return .verificationFailed }
guard let entitlement = info.entitlements.all[entitlementID] else { return .free(trialAvailable: false) }
guard entitlement.verification != .failed else { return .verificationFailed }
let ownership: ProOwnership = entitlement.ownershipType == .familyShared ? .familyShared : .purchased
if entitlement.expirationDate == nil {
return entitlement.isActive ? .purchased(ownership) : .free(trialAvailable: false)
}
let subscription = info.subscriptionsByProductIdentifier[entitlement.productIdentifier]
let expiration = entitlement.expirationDate
let phase: ProSubscriptionPhase
let deadline: Date?
if subscription?.refundedAt != nil {
phase = .revoked
deadline = expiration
} else if entitlement.isActive, let grace = subscription?.gracePeriodExpiresDate,
grace > now, subscription?.billingIssuesDetectedAt != nil
{
phase = .gracePeriod
deadline = grace
} else if entitlement.isActive, let expiration, expiration > now {
phase = entitlement.periodType == .trial ? .introductoryTrial : .subscribed
deadline = expiration
} else {
phase = entitlement.billingIssueDetectedAt == nil ? .expired : .billingRetry
deadline = expiration
}
return .subscription(
ProSubscriptionStatus(
phase: phase, expiresAt: deadline, willAutoRenew: entitlement.willRenew, ownership: ownership))
}
}
@@ -0,0 +1,80 @@
import Foundation
import RevenueCat
import TermindApplication
import TermindDomain
@MainActor
final class RevenueCatProPurchaseService: ProPurchaseService {
private let purchases: Purchases
private let configuration: RevenueCatConfiguration
private let defaults: UserDefaults
private let now: () -> Date
private var updateTask: Task<Void, Never>?
private var attemptedMigration = false
private let continuation: AsyncStream<EntitlementStatus>.Continuation
let updates: AsyncStream<EntitlementStatus>
init(
configuration: RevenueCatConfiguration, defaults: UserDefaults = .standard, now: @escaping () -> Date = { .now }
) {
self.configuration = configuration
self.defaults = defaults
self.now = now
// Both platforms use anonymous IDs. Restoring a universal Apple purchase aliases these IDs.
// Never use the device ID or iCloud account as a custom RevenueCat user ID.
if !Purchases.isConfigured {
Purchases.configure(
with: Configuration.builder(withAPIKey: configuration.apiKey)
.with(storeKitVersion: .storeKit2)
.with(entitlementVerificationMode: .informational)
.with(automaticDeviceIdentifierCollectionEnabled: false)
.with(preferredUILocaleOverride: AppLanguage.stored().locale.identifier)
)
}
purchases = Purchases.shared
(updates, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1))
let stream = purchases.customerInfoStream
updateTask = Task { [weak self] in
for await info in stream {
guard !Task.isCancelled, let self else { return }
self.continuation.yield(self.resolve(info))
}
}
}
isolated deinit {
updateTask?.cancel()
continuation.finish()
}
func currentStatus() async throws -> EntitlementStatus {
do {
// Revalidate deadlines against RevenueCat instead of accepting a still-fresh SDK cache.
let info = try await purchases.customerInfo(fetchPolicy: .fetchCurrent)
let status = resolve(info)
let migrationKey = "commerce.revenuecat.migrated.\(purchases.appUserID)"
if !configuration.isTestStore, !status.hasProAccess,
status != .verificationFailed, !attemptedMigration, !defaults.bool(forKey: migrationKey)
{
attemptedMigration = true
// RevenueCat imports pre-integration Apple purchases without a second transaction observer.
let migrated = resolve(try await purchases.syncPurchases())
if migrated != .verificationFailed { defaults.set(true, forKey: migrationKey) }
return migrated
}
return status
} catch {
if let cached = purchases.cachedCustomerInfo { return resolve(cached) }
throw error
}
}
func restore() async throws -> EntitlementStatus {
resolve(try await purchases.restorePurchases())
}
private func resolve(_ info: CustomerInfo) -> EntitlementStatus {
RevenueCatEntitlementResolver.resolve(info, entitlementID: configuration.entitlementID, now: now())
}
}
@@ -1,68 +0,0 @@
import Foundation
import TermindDomain
public nonisolated struct ProOfferPresentation: Equatable, Sendable {
public let purchaseButtonTitle: String
public let selectedPlanDetail: String?
public let trialBadge: String?
public let annualBillingUnit: String?
public init(
plan: ProPurchasePlan, annualPrice: String?, billingPeriod: DateComponents?,
trialDuration: DateComponents?, canStartTrial: Bool, locale: Locale,
localize: (String.LocalizationValue) -> String = { String(localized: $0) }
) {
let formatter = DateComponentsFormatter()
var calendar = Calendar(identifier: .gregorian)
calendar.locale = locale
formatter.calendar = calendar
formatter.unitsStyle = .full
formatter.maximumUnitCount = 1
annualBillingUnit = Self.billingUnit(billingPeriod, formatter: formatter, localize: localize)
let duration =
canStartTrial
? trialDuration.flatMap { period -> String? in
var components = period
// StoreKit weeks arrive as weekOfYear, which DateComponentsFormatter does not format.
// Preserve the offer's full duration by expressing those weeks as days.
if let weeks = components.weekOfYear {
components.weekOfYear = nil
components.day = (components.day ?? 0) + weeks * 7
}
return formatter.string(from: components)
} : nil
trialBadge = duration.map { localize("\($0) free") }
purchaseButtonTitle =
switch plan {
case .annual: localize(canStartTrial ? "Start Free Trial" : "Subscribe Annually")
case .lifetime: localize("Buy Lifetime Access")
}
switch plan {
case .annual:
guard let price = annualPrice, let billingUnit = annualBillingUnit else {
selectedPlanDetail = nil
return
}
if let duration {
selectedPlanDetail = localize(
"\(duration) free, then \(price) per \(billingUnit). Auto-renews until canceled.")
} else {
selectedPlanDetail = localize("\(price) per \(billingUnit). Auto-renews until canceled.")
}
case .lifetime:
selectedPlanDetail = localize("One payment. No subscription or automatic renewal.")
}
}
private static func billingUnit(
_ components: DateComponents?, formatter: DateComponentsFormatter,
localize: (String.LocalizationValue) -> String
) -> String? {
guard let components else { return nil }
if components.year == 1 { return localize("year") }
if components.month == 1 { return localize("month") }
if components.weekOfYear == 1 { return localize("week") }
if components.day == 1 { return localize("day") }
return formatter.string(from: components)
}
}
@@ -1,287 +0,0 @@
import SwiftUI
import TermindApplication
import TermindDomain
public struct ProPaywallContent: View {
@Environment(\.locale) private var locale
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Binding private var selectedPlan: ProPurchasePlan
private let background: Color
private let cardBackground: Color
private let presentation: ProPaywallPresentation
private let purchase: (ProPurchasePlan) -> Void
private let restorePurchases: () -> Void
private let reloadProducts: () -> Void
private let manageSubscription: () -> Void
public init(
presentation: ProPaywallPresentation, selectedPlan: Binding<ProPurchasePlan>,
background: Color, cardBackground: Color,
purchase: @escaping (ProPurchasePlan) -> Void, restorePurchases: @escaping () -> Void,
reloadProducts: @escaping () -> Void, manageSubscription: @escaping () -> Void
) {
self.background = background
self.cardBackground = cardBackground
self.presentation = presentation
_selectedPlan = selectedPlan
self.purchase = purchase
self.restorePurchases = restorePurchases
self.reloadProducts = reloadProducts
self.manageSubscription = manageSubscription
}
public var body: some View {
ScrollView {
VStack(spacing: 24) {
hero
accessStatus
featureList
if presentation.requestedFeature == .unlimitedMetrics {
Text(AppStrings.Pro.freeMetricsLimit)
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
secondaryActions
if dynamicTypeSize.isAccessibilitySize { purchaseActions }
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 20)
}
.safeAreaInset(edge: .bottom, spacing: 0) {
if !dynamicTypeSize.isAccessibilitySize { purchaseActions }
}
.background(background)
}
private var hero: some View {
VStack(spacing: 12) {
Image(systemName: "sparkles.rectangle.stack.fill")
.font(.system(size: 52, weight: .medium))
.foregroundStyle(.tint)
.symbolRenderingMode(.hierarchical)
Text(AppStrings.Pro.termindPro).font(.largeTitle.bold())
Text(AppStrings.Pro.workflowTagline)
.font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
private var accessStatus: some View {
let status = ProAccessPresentation(status: presentation.status)
let tint: Color =
switch status.tone {
case .neutral: .secondary
case .active: .green
case .warning: .orange
case .revoked: .red
}
return HStack(spacing: 12) {
Image(systemName: status.symbol).font(.title3.weight(.semibold)).foregroundStyle(tint)
.frame(width: 32, height: 32).background(tint.opacity(0.14), in: .circle)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(status.title).font(.headline)
if status.isFamilyShared, status.title.key != AppStrings.Settings.annualFamilyShared.key {
Text(AppStrings.Settings.annualFamilyShared).font(.caption).foregroundStyle(.secondary)
}
if let date = status.date, let label = status.dateLabel {
VStack(alignment: .leading, spacing: 2) {
Text(label)
Text(date, format: .dateTime.year().month().day().hour().minute())
}
.font(.caption).foregroundStyle(.secondary)
}
}
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(cardBackground, in: .rect(cornerRadius: 14))
.overlay { RoundedRectangle(cornerRadius: 14).strokeBorder(tint.opacity(0.22), lineWidth: 1) }
.accessibilityElement(children: .combine)
}
private var featureList: some View {
VStack(spacing: 0) {
feature(AppStrings.Pro.assistantFeature, symbol: "sparkles")
Divider().padding(.leading, 52)
feature(
presentation.supportsMCPServer
? AppStrings.Pro.connectMcpToolsAndRunTheBuiltInMcpServer : AppStrings.Pro.connectRemoteMcpTools,
symbol: "server.rack")
Divider().padding(.leading, 52)
feature(AppStrings.Pro.metricsFeature, symbol: "waveform.path.ecg")
Divider().padding(.leading, 52)
feature(AppStrings.Pro.terminalUploadFeature, symbol: "arrow.up.doc")
Divider().padding(.leading, 52)
feature(AppStrings.Pro.syncFeature, symbol: "icloud")
}
.background(cardBackground, in: .rect(cornerRadius: 16))
.overlay {
RoundedRectangle(cornerRadius: 16).strokeBorder(.quaternary, lineWidth: 1)
}
}
@ViewBuilder private var purchaseActions: some View {
if presentation.showsPurchaseActions {
VStack(spacing: 8) {
if presentation.isProcessing { ProgressView(AppStrings.Pro.contactingTheAppStoreProgress.swiftUIKey) }
if let message = presentation.message {
Text(message).font(.caption).foregroundStyle(.orange).multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
if presentation.status.canPurchase {
let offer = ProOfferPresentation(
plan: selectedPlan, annualPrice: presentation.annualPrice,
billingPeriod: presentation.billingPeriod, trialDuration: presentation.trialDuration,
canStartTrial: presentation.canStartTrial, locale: locale
) { AppLocalization.string($0, locale: locale) }
planPicker(offer)
Button {
purchase(selectedPlan)
} label: {
Text(offer.purchaseButtonTitle).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent).controlSize(.large)
.disabled(!presentation.canPurchase(selectedPlan))
if let detail = offer.selectedPlanDetail {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(.bottom, 6)
.background(background, ignoresSafeAreaEdges: .bottom)
.overlay(alignment: .top) { Divider() }
}
}
private var secondaryActions: some View {
VStack(spacing: 10) {
ViewThatFits(in: .horizontal) {
HStack(spacing: 10) { accountButtons }
.fixedSize(horizontal: true, vertical: false)
VStack(spacing: 10) { accountButtons }
}
.buttonStyle(.plain).foregroundStyle(.tint)
if presentation.needsProductReload {
Button(AppStrings.Pro.reloadAppStorePrices.swiftUIKey, action: reloadProducts)
.buttonStyle(.plain).foregroundStyle(.tint).disabled(presentation.isProcessing)
}
ViewThatFits(in: .horizontal) {
HStack(spacing: 16) { legalLinks }.fixedSize(horizontal: true, vertical: false)
VStack(spacing: 8) { legalLinks }
}
.font(.caption2)
.buttonStyle(.plain)
.foregroundStyle(.tint.opacity(0.7))
Text(AppStrings.Pro.familySharingDisclosure)
.font(.caption2).foregroundStyle(.tertiary).multilineTextAlignment(.center)
Text(AppStrings.Settings.aiProviderUsageAndChargesAreSeparateFromTermindPro)
.font(.caption2).foregroundStyle(.tertiary).multilineTextAlignment(.center)
}
}
@ViewBuilder private var accountButtons: some View {
if presentation.canManageSubscription {
Button(AppStrings.Pro.manageSubscription.swiftUIKey, action: manageSubscription)
}
Button(AppStrings.Pro.restorePurchase.swiftUIKey, action: restorePurchases)
.disabled(presentation.isProcessing)
}
@ViewBuilder private var legalLinks: some View {
Link(
AppStrings.Pro.privacyPolicy.swiftUIKey,
destination: URL(string: "https://termind.akinokaede.com/privacy/")!)
Link(
AppStrings.Pro.termsOfUse.swiftUIKey,
destination: URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")!)
}
private func planPicker(_ offer: ProOfferPresentation) -> some View {
let layout =
dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout(spacing: 8)) : AnyLayout(HStackLayout(spacing: 8))
return layout {
planCard(
.annual,
title: AppStrings.Pro.annual,
price: presentation.annualPrice,
detail: offer.annualBillingUnit.map {
AppLocalization.resolve(AppStrings.Pro.billingPeriod($0), locale: locale)
},
badge: offer.trialBadge)
planCard(
.lifetime,
title: AppStrings.Pro.lifetime,
price: presentation.lifetimePrice,
detail: AppLocalization.resolve(AppStrings.Pro.oneTimePurchase, locale: locale),
badge: nil)
}
}
private func planCard(
_ plan: ProPurchasePlan,
title: LocalizedStringResource,
price: String?,
detail: String?,
badge: String?
) -> some View {
let isSelected = selectedPlan == plan
let isAvailable = presentation.canPurchase(plan)
return Button {
selectedPlan = plan
} label: {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 4) {
Text(title).font(.subheadline.weight(.semibold))
Spacer(minLength: 0)
if let badge {
Text(badge)
.font(.caption2.weight(.semibold))
.foregroundStyle(.tint)
.fixedSize(horizontal: false, vertical: true)
}
}
Text(price ?? "—").font(.title3.bold()).foregroundStyle(.primary)
if let detail { Text(detail).font(.caption).foregroundStyle(.secondary) }
}
.frame(maxWidth: .infinity, minHeight: 56, alignment: .leading)
.padding(10)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.secondary.opacity(0.06))
.background(cardBackground)
.clipShape(.rect(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
isSelected ? Color.accentColor : Color.secondary.opacity(0.2),
lineWidth: isSelected ? 2 : 1)
}
}
.buttonStyle(.plain)
.focusEffectDisabled()
.disabled(!isAvailable)
.opacity(isAvailable ? 1 : 0.55)
}
private func feature(_ title: LocalizedStringResource, symbol: String) -> some View {
HStack(spacing: 12) {
Image(systemName: symbol).font(.body.weight(.medium)).foregroundStyle(.tint)
.frame(width: 32, height: 32).background(.tint.opacity(0.12), in: .rect(cornerRadius: 8))
.accessibilityHidden(true)
Text(title).frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 14)
.padding(.vertical, 11)
}
}
@@ -1,137 +0,0 @@
import Foundation
import TermindDomain
/// Store-independent values supplied by the platform's commerce adapter.
public nonisolated struct ProPaywallPresentation {
public var status: EntitlementStatus
public var annualPrice: String?
public var lifetimePrice: String?
public var billingPeriod: DateComponents?
public var trialDuration: DateComponents?
public var canStartTrial: Bool
public var canPurchaseAnnual: Bool
public var canPurchaseLifetime: Bool
public var isProcessing: Bool
public var message: String?
public var requestedFeature: ProFeature?
public var supportsMCPServer: Bool
public init(
status: EntitlementStatus, annualPrice: String? = nil, lifetimePrice: String? = nil,
billingPeriod: DateComponents? = nil, trialDuration: DateComponents? = nil,
canStartTrial: Bool = false, canPurchaseAnnual: Bool = false, canPurchaseLifetime: Bool = false,
isProcessing: Bool = false, message: String? = nil, requestedFeature: ProFeature? = nil,
supportsMCPServer: Bool = false
) {
self.status = status
self.annualPrice = annualPrice
self.lifetimePrice = lifetimePrice
self.billingPeriod = billingPeriod
self.trialDuration = trialDuration
self.canStartTrial = canStartTrial
self.canPurchaseAnnual = canPurchaseAnnual
self.canPurchaseLifetime = canPurchaseLifetime
self.isProcessing = isProcessing
self.message = message
self.requestedFeature = requestedFeature
self.supportsMCPServer = supportsMCPServer
}
public var canManageSubscription: Bool {
guard case .subscription(let subscription) = status else { return false }
return subscription.ownership == .purchased
}
public var needsProductReload: Bool { annualPrice == nil || lifetimePrice == nil }
public var showsPurchaseActions: Bool { isProcessing || message != nil || status.canPurchase }
public func canPurchase(_ plan: ProPurchasePlan) -> Bool {
guard status.canPurchase, !isProcessing else { return false }
switch plan {
case .annual: return annualPrice != nil && canPurchaseAnnual
case .lifetime: return lifetimePrice != nil && canPurchaseLifetime
}
}
}
/// The same entitlement labels and date semantics are used on both platforms.
public nonisolated struct ProAccessPresentation {
public enum Tone: Sendable { case neutral, active, warning, revoked }
public let title: LocalizedStringResource
public let symbol: String
public let tone: Tone
public let date: Date?
public let dateLabel: LocalizedStringResource?
public let isFamilyShared: Bool
public init(status: EntitlementStatus) {
switch status {
case .loading:
title = AppStrings.Pro.checkingProgress
symbol = "arrow.triangle.2.circlepath"
tone = .neutral
case .free:
title = AppStrings.Pro.free
symbol = "person.crop.circle"
tone = .neutral
case .purchased(let ownership):
title =
ownership == .familyShared ? AppStrings.Pro.lifetimeAccessFamilyShared : AppStrings.Pro.lifetimeAccess
symbol = ownership == .familyShared ? "person.3.fill" : "checkmark.seal.fill"
tone = .active
case .verificationFailed:
title = AppStrings.Pro.theAppStoreCouldNotVerifyThisPurchase
symbol = "exclamationmark.triangle.fill"
tone = .warning
case .unrestrictedForTesting:
title = AppStrings.Settings.developmentAccess
symbol = "hammer"
tone = .active
case .subscription(let subscription):
self = Self(subscription: subscription)
return
}
date = nil
dateLabel = nil
isFamilyShared = false
}
private init(subscription: ProSubscriptionStatus) {
date = subscription.expiresAt
isFamilyShared = subscription.ownership == .familyShared
switch subscription.phase {
case .introductoryTrial:
title = AppStrings.Pro.freeTrial
symbol = "clock.fill"
tone = .warning
dateLabel = AppStrings.Settings.trialEnds
case .subscribed:
title = isFamilyShared ? AppStrings.Settings.annualFamilyShared : AppStrings.Pro.annualSubscription
symbol = isFamilyShared ? "person.3.fill" : "checkmark.seal.fill"
tone = .active
dateLabel =
subscription.willAutoRenew == false ? AppStrings.Settings.accessEnds : AppStrings.Settings.renews
case .gracePeriod:
title = AppStrings.Pro.billingGracePeriod
symbol = "clock.badge.exclamationmark"
tone = .warning
dateLabel = AppStrings.Settings.gracePeriodEnds
case .billingRetry:
title = AppStrings.Pro.billingIssue
symbol = "creditcard.trianglebadge.exclamationmark"
tone = .warning
dateLabel = AppStrings.Settings.expired
case .expired:
title = AppStrings.Pro.subscriptionExpired
symbol = "calendar.badge.exclamationmark"
tone = .warning
dateLabel = AppStrings.Settings.expired
case .revoked:
title = AppStrings.Pro.subscriptionRevoked
symbol = "xmark.seal"
tone = .revoked
dateLabel = AppStrings.Settings.expired
}
}
}
@@ -0,0 +1,51 @@
import Foundation
import TermindDomain
/// Data-only contract for RevenueCat's remote visibility rules and localized text.
public nonisolated struct ProPaywallState {
public let hasProAccess: Bool
public let canManageSubscription: Bool
public let isFamilyShared: Bool
public let expiresAt: Date?
/// Protocol values consumed by the dashboard, never displayed as app copy.
public let state: String
public init(status: EntitlementStatus) {
hasProAccess = status.hasProAccess
switch status {
case .subscription(let subscription):
canManageSubscription = subscription.ownership == .purchased
isFamilyShared = subscription.ownership == .familyShared
expiresAt = subscription.expiresAt
switch subscription.phase {
case .introductoryTrial: state = "trial"
case .subscribed:
switch subscription.willAutoRenew {
case true: state = "renewing"
case false: state = "ending"
case nil: state = "active"
}
case .gracePeriod: state = "grace"
case .billingRetry: state = "retry"
case .expired: state = "expired"
case .revoked: state = "revoked"
}
default:
canManageSubscription = false
expiresAt = nil
if case .purchased(.familyShared) = status {
isFamilyShared = true
} else {
isFamilyShared = false
}
switch status {
case .loading: state = "loading"
case .free: state = "free"
case .purchased: state = "lifetime"
case .verificationFailed: state = "unverified"
case .unrestrictedForTesting: state = "development"
case .subscription: preconditionFailure("Handled above")
}
}
}
}
@@ -23,7 +23,7 @@ extension AppStrings {
}
public static var terminalUploadFeature: LocalizedStringResource {
"Upload files by dropping them on a terminal"
"Drag files to upload on terminal"
}
public static var syncFeature: LocalizedStringResource {
+28
View File
@@ -0,0 +1,28 @@
import AppKit
import SwiftUI
// Preserve the former native Pro hero in RevenueCat's remote image component.
// Run from the repository root: swift Scripts/render-pro-paywall-icon.swift
@MainActor
func renderIcon(name: String, red: Double, green: Double, blue: Double) throws {
let content = Image(systemName: "sparkles.rectangle.stack.fill")
.font(.system(size: 52, weight: .medium))
.foregroundStyle(.tint)
.tint(Color(red: red, green: green, blue: blue))
.symbolRenderingMode(.hierarchical)
.frame(width: 80, height: 72)
let renderer = ImageRenderer(content: content)
renderer.scale = 4
guard let cgImage = renderer.cgImage,
let png = NSBitmapImageRep(cgImage: cgImage).representation(using: .png, properties: [:])
else { throw CocoaError(.fileWriteUnknown) }
let directory = URL(filePath: FileManager.default.currentDirectoryPath)
.appending(path: "Design/Brand/ProPaywall", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try png.write(to: directory.appending(path: name))
}
try MainActor.assumeIsolated {
try renderIcon(name: "pro-hero-light.png", red: 0, green: 122.0 / 255, blue: 1)
try renderIcon(name: "pro-hero-dark.png", red: 10.0 / 255, green: 132.0 / 255, blue: 1)
}
+67 -37
View File
@@ -1,4 +1,4 @@
import StoreKit
import RevenueCatUI
import SwiftUI
import TermindDomain
import TermindInfrastructure
@@ -7,50 +7,80 @@ import TermindPresentation
struct MobileProView: View {
@Bindable var pro: ProAccessController
@Environment(\.dismiss) private var dismiss
@State private var isManagingSubscriptions = false
@State private var selectedPlan: ProPurchasePlan = .annual
@Environment(\.locale) private var locale
@State private var ignoresPurchaseDismissal = false
var body: some View {
NavigationStack {
ProPaywallContent(
presentation: presentation, selectedPlan: $selectedPlan,
background: Color(uiColor: .systemGroupedBackground),
cardBackground: Color(uiColor: .secondarySystemGroupedBackground), purchase: purchase,
restorePurchases: { Task { await pro.restorePurchases() } },
reloadProducts: { Task { await pro.reloadProducts() } },
manageSubscription: { isManagingSubscriptions = true }
)
.background(Color(uiColor: .systemGroupedBackground))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(AppStrings.Common.close.swiftUIKey, systemImage: "xmark") { dismiss() }
.labelStyle(.iconOnly)
VStack(spacing: 0) {
if pro.status == .loading {
ProgressView(AppStrings.Pro.contactingTheAppStoreProgress.swiftUIKey)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .topLeading) { closeButton }
} else if pro.usesRevenueCat {
PaywallView(displayCloseButton: true)
.customPaywallVariables(paywallVariables)
.onPurchaseInitiated { [pro] _, resume in
Task { @MainActor in resume(shouldProceed: !pro.hasProAccess) }
}
.onPurchaseStarted { _ in pro.beginPaywallOperation() }
.onRestoreStarted { pro.beginPaywallOperation() }
.onPurchaseCompleted { _ in
ignoresPurchaseDismissal = true
Task { await pro.completePaywallOperation() }
}
.onRestoreCompleted { _ in Task { await pro.completePaywallOperation() } }
.onPurchaseCancelled { pro.cancelPaywallOperation() }
.onPurchaseFailure { _ in pro.cancelPaywallOperation() }
.onRestoreFailure { _ in pro.cancelPaywallOperation() }
.onRequestedDismissal {
// RevenueCat requests dismissal after purchase; keep the account variant visible.
if ignoresPurchaseDismissal {
ignoresPurchaseDismissal = false
} else {
dismiss()
}
}
} else {
ContentUnavailableView {
Label(AppStrings.Pro.termindPro, systemImage: "cart")
} description: {
Text(ProAccessControllerError.productUnavailable.localizedDescription)
} actions: {
Button(AppStrings.Pro.restorePurchase) { Task { await pro.restorePurchases() } }
Button(AppStrings.Common.close) { dismiss() }
}
}
}
.mobileSheetPresentation()
.task {
await pro.reloadProducts()
if pro.annualProduct == nil, pro.product != nil { selectedPlan = .lifetime }
}
.manageSubscriptionsSheet(isPresented: $isManagingSubscriptions)
.onChange(of: pro.isPaywallPresented) { if !pro.isPaywallPresented { dismiss() } }
.onDisappear { pro.cancelPaywallOperation() }
}
private var paywallVariables: [String: CustomVariableValue] {
let account = ProPaywallState(status: pro.status)
return [
"pro": .bool(account.hasProAccess),
"manage": .bool(account.canManageSubscription),
"state": .string(account.state),
"family": .bool(account.isFamilyShared),
"dated": .bool(account.expiresAt != nil),
"date": .string(
account.expiresAt?.formatted(
.dateTime.year().month().day().hour().minute().locale(locale)) ?? ""),
]
}
private var presentation: ProPaywallPresentation {
ProPaywallPresentation(
status: pro.status, annualPrice: pro.annualDisplayPrice, lifetimePrice: pro.displayPrice,
billingPeriod: pro.annualBillingPeriod, trialDuration: pro.annualIntroOfferDuration,
canStartTrial: pro.canStartTrial, canPurchaseAnnual: pro.canPurchase(.annual),
canPurchaseLifetime: pro.canPurchase(.lifetime), isProcessing: pro.isProcessing,
message: pro.message, requestedFeature: pro.requestedFeature, supportsMCPServer: false)
private var closeButton: some View {
Button {
dismiss()
} label: {
Label(AppStrings.Common.close, systemImage: "xmark")
.labelStyle(.iconOnly)
.font(.system(size: 20, weight: .medium))
.frame(width: 44, height: 44)
.contentShape(.rect)
}
.buttonStyle(.plain)
.padding(16)
}
private func purchase(_ plan: ProPurchasePlan) {
Task {
switch plan {
case .annual: await pro.purchaseAnnual()
case .lifetime: await pro.purchaseLifetime()
}
}
}
}
+4
View File
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>TermindRevenueCatAPIKey</key>
<string>$(REVENUECAT_API_KEY)</string>
<key>TermindRevenueCatEntitlementID</key>
<string>$(REVENUECAT_ENTITLEMENT_ID)</string>
<key>FirebaseCrashlyticsCollectionEnabled</key>
<false/>
<key>FirebaseDataCollectionDefaultEnabled</key>
-85
View File
@@ -1,85 +0,0 @@
import SwiftUI
import TermindDomain
import TermindPresentation
import Testing
import UIKit
/// Native fixtures use the app's localized resources without contacting StoreKit.
@Suite(
.enabled(
if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1",
"Set TERMIND_MOBILE_UI_TESTS=1 and disable parallel testing for key-window integration tests"),
.serialized) @MainActor
struct ProPaywallRenderingTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [false, true])
func sharedContentFitsThePlatformAndLargerText(language: String, largeText: Bool) async throws {
let sizes = [CGSize(width: 375, height: 760), CGSize(width: 700, height: 850)]
for size in sizes {
for subscribed in [false, true] {
let status: EntitlementStatus =
subscribed
? .subscription(
ProSubscriptionStatus(
phase: .subscribed, expiresAt: Date(timeIntervalSince1970: 1_800_000_000),
willAutoRenew: false, ownership: .purchased))
: .free(trialAvailable: true)
let presentation = ProPaywallPresentation(
status: status, annualPrice: "$19.99", lifetimePrice: "$49.99",
billingPeriod: DateComponents(year: 1), trialDuration: DateComponents(weekOfYear: 2),
canStartTrial: !subscribed, canPurchaseAnnual: !subscribed, canPurchaseLifetime: !subscribed)
let content = ProPaywallContent(
presentation: presentation, selectedPlan: .constant(.annual),
background: Color(uiColor: .systemGroupedBackground),
cardBackground: Color(uiColor: .secondarySystemGroupedBackground), purchase: { _ in },
restorePurchases: {}, reloadProducts: {}, manageSubscription: {}
)
.environment(\.locale, Locale(identifier: language))
.environment(\.dynamicTypeSize, largeText ? .accessibility3 : .large)
.background(.background)
let name = "pro-\(language)-\(Int(size.width))-\(largeText ? "large" : "regular")-\(subscribed).png"
let host = UIHostingController(rootView: content)
host.loadViewIfNeeded()
let previousWindow = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?
.keyWindow
let window = UIWindow(frame: CGRect(origin: .zero, size: size))
window.rootViewController = host
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previousWindow?.makeKey()
}
_ = host.sizeThatFits(in: size)
host.view.frame = CGRect(origin: .zero, size: size)
host.view.setNeedsLayout()
host.view.layoutIfNeeded()
try await Task.sleep(for: .milliseconds(150))
host.view.layoutIfNeeded()
let scroll = try #require(descendants(host.view).compactMap { $0 as? UIScrollView }.first)
#expect(scroll.contentSize.width <= scroll.bounds.width + 1)
#expect(scroll.bounds.height > 100)
if largeText {
scroll.setContentOffset(
CGPoint(
x: 0,
y: max(
0, scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom
)),
animated: false)
host.view.layoutIfNeeded()
}
let data = UIGraphicsImageRenderer(size: size).pngData { context in
host.view.layer.render(in: context.cgContext)
}
Attachment.record(data, named: name)
let directory = FileManager.default.temporaryDirectory.appending(path: "termind-pro-previews")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: directory.appending(path: name))
}
}
}
private func descendants(_ view: UIView) -> [UIView] {
[view] + view.subviews.flatMap(descendants)
}
}
+62 -41
View File
@@ -1,5 +1,5 @@
import RevenueCatUI
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPresentation
@@ -7,27 +7,68 @@ import TermindPresentation
struct ProPaywallView: View {
@Environment(AppModel.self) private var app
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var selectedPlan: ProPurchasePlan = .annual
@Environment(\.locale) private var locale
@State private var ignoresPurchaseDismissal = false
private var pro: ProAccessController { app.proAccess }
var body: some View {
ProPaywallContent(
presentation: presentation, selectedPlan: $selectedPlan,
background: TermindColor.windowSurface, cardBackground: TermindColor.contentSurface, purchase: purchase,
restorePurchases: { Task { await pro.restorePurchases() } },
reloadProducts: { Task { await pro.reloadProducts() } },
manageSubscription: { openURL(URL(string: "https://apps.apple.com/account/subscriptions")!) }
)
.frame(width: 604, height: 640)
.overlay(alignment: .topLeading) {
closeButton.padding(16)
}
.task {
await pro.reloadProducts()
if pro.annualProduct == nil, pro.product != nil { selectedPlan = .lifetime }
VStack(spacing: 0) {
if pro.status == .loading {
ProgressView(AppStrings.Pro.contactingTheAppStoreProgress.swiftUIKey)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .topLeading) { closeButton }
} else if pro.usesRevenueCat {
PaywallView(displayCloseButton: true)
.customPaywallVariables(paywallVariables)
.onPurchaseInitiated { [pro] _, resume in
Task { @MainActor in resume(shouldProceed: !pro.hasProAccess) }
}
.onPurchaseStarted { _ in pro.beginPaywallOperation() }
.onRestoreStarted { pro.beginPaywallOperation() }
.onPurchaseCompleted { _ in
ignoresPurchaseDismissal = true
Task { await pro.completePaywallOperation() }
}
.onRestoreCompleted { _ in Task { await pro.completePaywallOperation() } }
.onPurchaseCancelled { pro.cancelPaywallOperation() }
.onPurchaseFailure { _ in pro.cancelPaywallOperation() }
.onRestoreFailure { _ in pro.cancelPaywallOperation() }
.onRequestedDismissal {
// RevenueCat requests dismissal after purchase; keep the account variant visible.
if ignoresPurchaseDismissal {
ignoresPurchaseDismissal = false
} else {
dismiss()
}
}
} else {
ContentUnavailableView {
Label(AppStrings.Pro.termindPro, systemImage: "cart")
} description: {
Text(ProAccessControllerError.productUnavailable.localizedDescription)
} actions: {
Button(AppStrings.Pro.restorePurchase) { Task { await pro.restorePurchases() } }
Button(AppStrings.Common.close) { dismiss() }
}
}
}
.frame(width: 604, height: 720)
.onChange(of: pro.isPaywallPresented) { if !pro.isPaywallPresented { dismiss() } }
.onDisappear { pro.cancelPaywallOperation() }
}
private var paywallVariables: [String: CustomVariableValue] {
let account = ProPaywallState(status: pro.status)
return [
"pro": .bool(account.hasProAccess),
"manage": .bool(account.canManageSubscription),
"state": .string(account.state),
"family": .bool(account.isFamilyShared),
"dated": .bool(account.expiresAt != nil),
"date": .string(
account.expiresAt?.formatted(
.dateTime.year().month().day().hour().minute().locale(locale)) ?? ""),
]
}
private var closeButton: some View {
@@ -36,34 +77,14 @@ struct ProPaywallView: View {
} label: {
Label(AppStrings.Common.close, systemImage: "xmark")
.labelStyle(.iconOnly)
.frame(width: 30, height: 30)
.contentShape(.circle)
.font(.system(size: 20, weight: .medium))
.frame(width: 44, height: 44)
.contentShape(.rect)
}
.buttonStyle(.plain)
.termindGlassCircle(isInteractive: true)
.termindInputAwareFocusEffect()
.keyboardShortcut(.cancelAction)
.help(AppStrings.Common.close)
.accessibilityLabel(Text(AppStrings.Common.close))
.padding(16)
}
private var presentation: ProPaywallPresentation {
ProPaywallPresentation(
status: pro.status, annualPrice: pro.annualDisplayPrice, lifetimePrice: pro.displayPrice,
billingPeriod: pro.annualBillingPeriod, trialDuration: pro.annualIntroOfferDuration,
canStartTrial: pro.canStartTrial, canPurchaseAnnual: pro.canPurchase(.annual),
canPurchaseLifetime: pro.canPurchase(.lifetime), isProcessing: pro.isProcessing,
message: pro.message, requestedFeature: pro.requestedFeature, supportsMCPServer: true)
}
private func purchase(_ plan: ProPurchasePlan) {
Task {
switch plan {
case .annual: await pro.purchaseAnnual()
case .lifetime: await pro.purchaseLifetime()
}
}
}
}
struct ProPaywallPresenter: ViewModifier {
+4
View File
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>TermindRevenueCatAPIKey</key>
<string>$(REVENUECAT_API_KEY)</string>
<key>TermindRevenueCatEntitlementID</key>
<string>$(REVENUECAT_ENTITLEMENT_ID)</string>
<key>FirebaseCrashlyticsCollectionEnabled</key>
<false/>
<key>FirebaseDataCollectionDefaultEnabled</key>
-21
View File
@@ -99,27 +99,6 @@ struct AppLanguageTests {
}
}
@Test(
arguments: [AppLanguage.english, .simplifiedChinese, .traditionalChinese],
[ProPurchasePlan.annual, .lifetime])
func proOfferUsesTheExplicitAppLanguage(language: AppLanguage, plan: ProPurchasePlan) throws {
let locale = language.locale
let price = "$12.34"
let offer = ProOfferPresentation(
plan: plan, annualPrice: price, billingPeriod: DateComponents(year: 1),
trialDuration: DateComponents(weekOfYear: 2), canStartTrial: true, locale: locale
) { AppLocalization.string($0, locale: locale) }
let title: String.LocalizationValue = plan == .annual ? "Start Free Trial" : "Buy Lifetime Access"
#expect(offer.purchaseButtonTitle == AppLocalization.string(title, locale: locale))
#expect(offer.annualBillingUnit == AppLocalization.string("year", locale: locale))
#expect(try #require(offer.trialBadge).contains("14"))
#expect(!(try #require(offer.selectedPlanDetail)).isEmpty)
if plan == .annual { #expect(offer.selectedPlanDetail?.contains(price) == true) }
if language != .english {
#expect(offer.purchaseButtonTitle != AppLocalization.string(title, locale: AppLanguage.english.locale))
}
}
@Test
func followSystemRemovesAppleLanguageOverride() throws {
let suiteName = "AppLanguageTests.\(UUID().uuidString)"
+8 -3
View File
@@ -3,6 +3,11 @@ import Testing
@Suite
struct ArchitectureBoundaryTests {
@Test
func revenueCatOwnsCommerceTransactions() throws {
try assertNoImports(in: "../Frameworks/TermindInfrastructure/Commerce", forbidden: ["StoreKit"])
}
@Test
func diagnosisSDKStaysBehindInfrastructureAndStartsWithCollectionDisabled() throws {
for module in ["TermindDomain", "TermindApplication", "TermindPresentation", "TermindPersistence"] {
@@ -97,7 +102,7 @@ struct ArchitectureBoundaryTests {
in: "../Frameworks/TermindDomain",
forbidden: [
"SwiftUI", "AppKit", "UIKit", "SwiftData", "GRDB", "Security", "Network", "StoreKit", "CloudKit",
"CSSH2", "OpenSSL", "GhosttyTerminal", "GhosttyTheme",
"CSSH2", "OpenSSL", "GhosttyTerminal", "GhosttyTheme", "RevenueCat", "RevenueCatUI",
]
)
}
@@ -192,7 +197,7 @@ struct ArchitectureBoundaryTests {
}
try assertNoImports(
in: "../Frameworks/TermindApplication",
forbidden: ["TermindInfrastructure", "TermindPersistence", "SwiftData"])
forbidden: ["TermindInfrastructure", "TermindPersistence", "SwiftData", "RevenueCat", "RevenueCatUI"])
try assertNoImports(
in: "../Frameworks/TermindPersistence",
forbidden: ["TermindInfrastructure", "TermindRemoteInfrastructure"])
@@ -204,7 +209,7 @@ struct ArchitectureBoundaryTests {
in: "../Frameworks/TermindPresentation",
forbidden: [
"Termind", "TermindIOS", "UIKit", "AppKit", "SwiftData", "GRDB", "TermindPersistence",
"TermindRemoteInfrastructure", "TermindInfrastructure", "StoreKit",
"TermindRemoteInfrastructure", "TermindInfrastructure", "StoreKit", "RevenueCat", "RevenueCatUI",
])
}
+4 -4
View File
@@ -11,7 +11,7 @@ import Testing
struct CommerceConfigurationTests {
@Test
func localStoreKitProductMatchesPurchaseRequest() throws {
func localStoreKitFixtureContainsAnnualAndLifetimeProducts() throws {
let configurationFile = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
@@ -23,9 +23,9 @@ struct CommerceConfigurationTests {
let products = try #require(configuration["products"] as? [[String: Any]])
let productIDs = products.compactMap { $0["productID"] as? String }
#expect(Set(productIDs) == [ProAccessController.lifetimeProductID])
#expect(Set(productIDs) == ["com.akinokaede.termind.pro"])
let pro = try #require(
products.first { $0["productID"] as? String == ProAccessController.lifetimeProductID })
products.first { $0["productID"] as? String == "com.akinokaede.termind.pro" })
#expect(pro["displayPrice"] as? String == "9.99")
#expect(pro["familyShareable"] as? Bool == true)
@@ -33,7 +33,7 @@ struct CommerceConfigurationTests {
let group = try #require(groups.first)
let subscriptions = try #require(group["subscriptions"] as? [[String: Any]])
let annual = try #require(
subscriptions.first { $0["productID"] as? String == ProAccessController.annualProductID })
subscriptions.first { $0["productID"] as? String == "com.akinokaede.termind.pro.annual" })
#expect(annual["displayPrice"] as? String == "3.99")
#expect(annual["familyShareable"] as? Bool == true)
#expect(annual["recurringSubscriptionPeriod"] as? String == "P1Y")
+24 -230
View File
@@ -1,16 +1,8 @@
import Foundation
import StoreKit
import StoreKitTest
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
@testable import Termind
@Suite(
.enabled(
if: ProcessInfo.processInfo.environment["TERMIND_STOREKIT_TESTS"] == "1",
@@ -18,229 +10,31 @@ import Testing
.serialized)
@MainActor
struct StoreKitTrialTests {
// This local fixture validates catalog metadata only. RevenueCatCommerceTests covers app access.
@Test
func annualProductSuppliesPriceBillingPeriodTrialDurationAndEntitlement() async throws {
let fixture = try Fixture()
let access = fixture.controller()
await access.refresh()
await access.reloadProducts()
func fixtureHasTheUniversalAnnualAndLifetimeProductsWithAFreeIntroOffer() async throws {
let configuration = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
.appending(path: "StoreKit/Termind.storekit")
let session = try SKTestSession(contentsOf: configuration)
session.resetToDefaultState()
session.disableDialogs = true
defer { session.resetToDefaultState() }
#expect(access.annualProduct?.displayPrice == "$3.99")
#expect(access.displayPrice == "$9.99")
#expect(access.annualBillingPeriod?.year == 1)
#expect(access.annualIntroOfferDuration?.weekOfYear == 2)
#expect(access.canStartTrial)
access.isPaywallPresented = true
await access.purchaseAnnual()
guard case .subscription(let subscription) = access.status else {
Issue.record("Annual purchase did not produce subscription access: \(access.status)")
return
}
#expect(subscription.phase == .introductoryTrial)
#expect(subscription.expiresAt != nil)
#expect(access.hasProAccess)
#expect(!access.isPaywallPresented)
#expect(!access.canPurchaseAnnual)
#expect(!access.canPurchase)
let relaunched = fixture.controller()
await relaunched.reloadProducts()
await relaunched.restorePurchases()
#expect(relaunched.status.hasProAccess)
#expect(relaunched.error == nil)
}
@Test
func lifetimePurchaseTakesPrecedenceOverAnnualSubscription() async throws {
let fixture = try Fixture()
try await fixture.purchase(ProAccessController.annualProductID)
try await fixture.purchase(ProAccessController.lifetimeProductID)
let access = fixture.controller()
await access.refresh()
#expect(access.status == .purchased(.purchased))
#expect(access.hasProAccess)
#expect(!access.status.canPurchase)
}
@Test
func cancellationAndNetworkFailuresDoNotRemoveAnnualAccess() async throws {
let fixture = try Fixture()
try await fixture.purchase(ProAccessController.annualProductID)
let access = fixture.controller()
await access.refresh()
let active = access.status
try await fixture.session.setSimulatedError(.generic(.userCancelled), forAPI: .purchase)
await access.purchaseAnnual()
#expect(access.status == active)
#expect(access.error == nil)
try await fixture.session.setSimulatedError(
.generic(.networkError(URLError(.notConnectedToInternet))), forAPI: .loadProducts)
await access.reloadProducts()
#expect(access.status == active)
#expect(access.message == nil)
}
@Test
func pendingAnnualPurchaseBlocksRepeatedRequestsAndVerifiedUpdateCompletesAccess() async throws {
let fixture = try Fixture()
fixture.session.askToBuyEnabled = true
let access = fixture.controller()
await access.refresh()
await access.reloadProducts()
let first = Task { await access.purchaseAnnual() }
await Task.yield()
await access.purchaseAnnual()
await first.value
#expect(access.pendingProductID == ProAccessController.annualProductID)
#expect(!access.canPurchaseAnnual)
#expect(fixture.session.allTransactions().count == 1)
let pending = try #require(fixture.session.allTransactions().first)
try fixture.session.approveAskToBuyTransaction(identifier: pending.identifier)
var approved: VerificationResult<Transaction>?
for _ in 0..<100 where approved == nil {
approved = await Transaction.latest(for: ProAccessController.annualProductID)
if approved == nil { try await Task.sleep(for: .milliseconds(50)) }
}
await access.handleTransactionUpdate(try #require(approved))
#expect(access.hasProAccess)
#expect(access.pendingProductID == nil)
}
@Test
func unverifiedAnnualTransactionNeverGrantsAccess() async throws {
let fixture = try Fixture()
try await fixture.session.setSimulatedError(.verification(.invalidSignature), forAPI: .verification)
let access = fixture.controller()
await access.refresh()
await access.reloadProducts()
await access.purchaseAnnual()
#expect(access.error == .purchaseVerificationFailed)
await access.refresh()
#expect(!access.hasProAccess)
}
@Test
func annualRemainsPurchasableWhenTheIntroductoryOfferIsRemoved() async throws {
let url = try Fixture.modifiedConfiguration("missingOffer")
defer { try? FileManager.default.removeItem(at: url) }
let fixture = try Fixture(configuration: url)
let access = fixture.controller()
await access.refresh()
await access.reloadProducts()
#expect(access.canPurchaseAnnual)
#expect(!access.canStartTrial)
#expect(access.annualIntroOfferDuration == nil)
await access.purchaseAnnual()
guard case .subscription(let subscription) = access.status else {
Issue.record("Annual purchase without a trial did not produce subscription access")
return
}
#expect(subscription.phase == .subscribed)
#expect(subscription.expiresAt != nil)
}
@Test(arguments: ["monthly", "unshared", "missingAnnual"])
func invalidAnnualConfigurationCannotBePurchased(configuration: String) async throws {
let url = try Fixture.modifiedConfiguration(configuration)
defer { try? FileManager.default.removeItem(at: url) }
let fixture = try Fixture(configuration: url)
let access = fixture.controller()
await access.refresh()
await access.reloadProducts()
#expect(!access.canPurchaseAnnual)
#expect(!access.canStartTrial)
#expect(access.canPurchase)
await access.purchaseAnnual()
#expect(!access.hasProAccess)
#expect(fixture.session.allTransactions().isEmpty)
}
@Test
func restoringAnAccountWithoutPurchasesReportsNoActivePurchase() async throws {
let fixture = try Fixture()
let access = fixture.controller()
await access.reloadProducts()
await access.restorePurchases()
#expect(access.status == .free(trialAvailable: true))
#expect(access.error == .purchaseNotFound)
}
private final class Fixture {
static var configurationURL: URL {
URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
.appending(path: "StoreKit/Termind.storekit")
}
let session: SKTestSession
init(configuration: URL = configurationURL) throws {
session = try SKTestSession(contentsOf: configuration)
session.resetToDefaultState()
session.disableDialogs = true
session.clearTransactions()
}
isolated deinit {
session.clearTransactions()
session.resetToDefaultState()
}
func controller(now: @escaping () -> Date = { .now }) -> ProAccessController {
ProAccessController(now: now, observeStore: false)
}
func purchase(_ identifier: Product.ID) async throws {
let updates = Task {
for await _ in Transaction.updates {
guard !Task.isCancelled else { return }
}
}
defer { updates.cancel() }
await Task.yield()
let products = try await Product.products(for: [identifier])
let product = try #require(products.first { $0.id == identifier })
let result = try await product.purchase()
guard case .success(let verification) = result else {
Issue.record("StoreKit did not complete the \(identifier) purchase: \(result)")
return
}
let transaction = try verification.payloadValue
await transaction.finish()
for _ in 0..<100 {
if await Transaction.latest(for: identifier) != nil { return }
try await Task.sleep(for: .milliseconds(50))
}
Issue.record("StoreKit did not publish the \(identifier) transaction")
}
static func modifiedConfiguration(_ name: String) throws -> URL {
var root = try #require(
JSONSerialization.jsonObject(with: Data(contentsOf: configurationURL)) as? [String: Any])
var groups = try #require(root["subscriptionGroups"] as? [[String: Any]])
var subscriptions = try #require(groups[0]["subscriptions"] as? [[String: Any]])
let index = try #require(
subscriptions.firstIndex { $0["productID"] as? String == ProAccessController.annualProductID })
switch name {
case "monthly": subscriptions[index]["recurringSubscriptionPeriod"] = "P1M"
case "unshared": subscriptions[index]["familyShareable"] = false
case "missingOffer": subscriptions[index]["introductoryOffer"] = NSNull()
case "missingAnnual": subscriptions.remove(at: index)
default: preconditionFailure("Unknown test configuration")
}
groups[0]["subscriptions"] = subscriptions
root["subscriptionGroups"] = groups
let url = FileManager.default.temporaryDirectory.appending(path: "\(UUID().uuidString).storekit")
try JSONSerialization.data(withJSONObject: root).write(to: url)
return url
}
let products = try await Product.products(
for: ["com.akinokaede.termind.pro.annual", "com.akinokaede.termind.pro"])
let annual = try #require(products.first { $0.id == "com.akinokaede.termind.pro.annual" })
let lifetime = try #require(products.first { $0.id == "com.akinokaede.termind.pro" })
#expect(annual.type == .autoRenewable)
#expect(annual.isFamilyShareable)
#expect(lifetime.type == .nonConsumable)
#expect(lifetime.isFamilyShareable)
let subscription = try #require(annual.subscription)
#expect(subscription.subscriptionPeriod.unit == .year)
#expect(subscription.subscriptionPeriod.value == 1)
let offer = try #require(subscription.introductoryOffer)
#expect(offer.paymentMode == .freeTrial)
#expect(offer.price == .zero)
#expect(offer.period.unit == .week)
#expect(offer.period.value * offer.periodCount == 2)
}
}
+41
View File
@@ -68,6 +68,12 @@
BENC00000000000000000002 /* TermindRemoteInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; };
BENC00000000000000000003 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; };
BENC00000000000000000004 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; };
C0CA00000000000000000101 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = C0CA00000000000000000002 /* RevenueCat */; };
C0CA00000000000000000102 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = C0CA00000000000000000002 /* RevenueCat */; };
C0CA00000000000000000103 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = C0CA00000000000000000002 /* RevenueCat */; };
C0CA00000000000000000104 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = C0CA00000000000000000002 /* RevenueCat */; };
C0CA00000000000000000201 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = C0CA00000000000000000003 /* RevenueCatUI */; };
C0CA00000000000000000202 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = C0CA00000000000000000003 /* RevenueCatUI */; };
C2D4B372BA5A040DDBCEFC85 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; };
C4AD00000000000000000001 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100015 /* ZIPFoundation */; };
C4AD00000000000000000002 /* MarkdownView in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100017 /* MarkdownView */; };
@@ -651,6 +657,8 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C0CA00000000000000000202 /* RevenueCatUI in Frameworks */,
C0CA00000000000000000103 /* RevenueCat in Frameworks */,
F1BA00000000000000000302 /* FirebaseCrashlytics in Frameworks */,
F1BA00000000000000000301 /* FirebaseCore in Frameworks */,
DEVK00000000000000000003 /* DeviceKit in Frameworks */,
@@ -676,6 +684,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C0CA00000000000000000101 /* RevenueCat in Frameworks */,
F1BA00000000000000000102 /* FirebaseCrashlytics in Frameworks */,
F1BA00000000000000000101 /* FirebaseCore in Frameworks */,
LOGG00000000000000000102 /* Logging in Frameworks */,
@@ -727,6 +736,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C0CA00000000000000000104 /* RevenueCat in Frameworks */,
F1BA00000000000000000402 /* FirebaseCrashlytics in Frameworks */,
F1BA00000000000000000401 /* FirebaseCore in Frameworks */,
LOGG00000000000000000106 /* Logging in Frameworks */,
@@ -754,6 +764,8 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C0CA00000000000000000201 /* RevenueCatUI in Frameworks */,
C0CA00000000000000000102 /* RevenueCat in Frameworks */,
F1BA00000000000000000202 /* FirebaseCrashlytics in Frameworks */,
F1BA00000000000000000201 /* FirebaseCore in Frameworks */,
DEVK00000000000000000004 /* DeviceKit in Frameworks */,
@@ -915,6 +927,8 @@
);
name = TFI;
packageProductDependencies = (
C0CA00000000000000000003 /* RevenueCatUI */,
C0CA00000000000000000002 /* RevenueCat */,
F1BA00000000000000000002 /* FirebaseCrashlytics */,
F1BA00000000000000000001 /* FirebaseCore */,
DEVK00000000000000000002 /* DeviceKit */,
@@ -956,6 +970,7 @@
);
name = FrameworkTests;
packageProductDependencies = (
C0CA00000000000000000002 /* RevenueCat */,
F1BA00000000000000000002 /* FirebaseCrashlytics */,
F1BA00000000000000000001 /* FirebaseCore */,
LOGG00000000000000000002 /* Logging */,
@@ -1040,6 +1055,7 @@
);
name = TermindInfrastructure;
packageProductDependencies = (
C0CA00000000000000000002 /* RevenueCat */,
F1BA00000000000000000002 /* FirebaseCrashlytics */,
F1BA00000000000000000001 /* FirebaseCore */,
LOGG00000000000000000002 /* Logging */,
@@ -1233,6 +1249,8 @@
);
name = TFM;
packageProductDependencies = (
C0CA00000000000000000003 /* RevenueCatUI */,
C0CA00000000000000000002 /* RevenueCat */,
F1BA00000000000000000002 /* FirebaseCrashlytics */,
F1BA00000000000000000001 /* FirebaseCore */,
DEVK00000000000000000002 /* DeviceKit */,
@@ -1367,6 +1385,7 @@
mainGroup = TRMD00000000000000000030;
minimizedProjectReferenceProxies = 1;
packageReferences = (
C0CA00000000000000000001 /* XCRemoteSwiftPackageReference "purchases-ios" */,
F1BA00000000000000000010 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */,
BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */,
@@ -2480,6 +2499,8 @@
"$(inherited)",
"-liconv",
);
REVENUECAT_API_KEY = REDACTED_REVENUECAT_API_KEY;
REVENUECAT_ENTITLEMENT_ID = termind_pro;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.1*]" = "$(inherited) TERMIND_HAS_IOS_27_1_SDK";
@@ -2544,6 +2565,8 @@
"$(inherited)",
"-liconv",
);
REVENUECAT_API_KEY = REDACTED_REVENUECAT_API_KEY;
REVENUECAT_ENTITLEMENT_ID = termind_pro;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.1*]" = "$(inherited) TERMIND_HAS_IOS_27_1_SDK";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.1*]" = "$(inherited) TERMIND_HAS_IOS_27_1_SDK";
@@ -2875,6 +2898,14 @@
version = 7.11.1;
};
};
C0CA00000000000000000001 /* XCRemoteSwiftPackageReference "purchases-ios" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/RevenueCat/purchases-ios.git";
requirement = {
kind = exactVersion;
version = 5.92.0;
};
};
DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/devicekit/DeviceKit.git";
@@ -2971,6 +3002,16 @@
package = BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */;
productName = GRDB;
};
C0CA00000000000000000002 /* RevenueCat */ = {
isa = XCSwiftPackageProductDependency;
package = C0CA00000000000000000001 /* XCRemoteSwiftPackageReference "purchases-ios" */;
productName = RevenueCat;
};
C0CA00000000000000000003 /* RevenueCatUI */ = {
isa = XCSwiftPackageProductDependency;
package = C0CA00000000000000000001 /* XCRemoteSwiftPackageReference "purchases-ios" */;
productName = RevenueCatUI;
};
DEVK00000000000000000002 /* DeviceKit */ = {
isa = XCSwiftPackageProductDependency;
package = DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */;
@@ -1,5 +1,5 @@
{
"originHash" : "db5fde46a07b58b12a3db946618d16496080b8cba0501fc05694825a35f1a1b3",
"originHash" : "b4d0d2a0d4dd48bc17305a7633056c1a5aeddd04bed354f9616d8b1b31e4e888",
"pins" : [
{
"identity" : "abseil-cpp-binary",
@@ -244,6 +244,15 @@
"version" : "2.4.1"
}
},
{
"identity" : "purchases-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/RevenueCat/purchases-ios.git",
"state" : {
"revision" : "e45bb84b726885492ce4e7ee94ddb252d171a205",
"version" : "5.92.0"
}
},
{
"identity" : "respring",
"kind" : "remoteSourceControl",