feat: unify Pro paywall in macOS and iOS

This commit is contained in:
Kaede Akino committed 2026-09-24 11:22:55 +08:00
1 parent 7dd53bece9
commit 8edb4e8213
16 files changed
+822 -789

No files matched your search

@@ -4,6 +4,24 @@ 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()
@@ -0,0 +1,98 @@
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))
}
}
+17 -176
View File
@@ -2156,25 +2156,6 @@
}
}
},
"Access" : {
"comment" : "A section in the settings that shows the user's access to Termind Pro.",
"extractionState" : "manual",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用权限"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用權限"
}
}
}
},
"Access Ends" : {
"extractionState" : "manual",
"localizations" : {
@@ -3180,23 +3161,6 @@
}
}
},
"Annual Access Until" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "年度权益有效期"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "年度權益有效期"
}
}
}
},
"Annual Subscription" : {
"extractionState" : "manual",
"localizations" : {
@@ -3369,25 +3333,6 @@
}
}
},
"Apple Family Sharing" : {
"comment" : "A feature that allows multiple users to use the same Apple devices.",
"extractionState" : "manual",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apple家庭共享"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apple家庭共享"
}
}
}
},
"Apple Family Sharing Supported" : {
"extractionState" : "manual",
"localizations" : {
@@ -5146,40 +5091,6 @@
}
}
},
"Choose annual access or a permanent unlock for the AI Assistant, terminal uploads, MCP, and iCloud sync." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "选择年度订阅或永久解锁AI助手、终端上传、MCP与iCloud同步。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "選擇年度訂閱或永久解鎖AI助理、終端上傳、MCP與iCloud同步。"
}
}
}
},
"Choose annual access or unlock Pro permanently." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "选择年度订阅或永久解锁Pro。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "選擇年度訂閱或永久解鎖Pro。"
}
}
}
},
"Choose or drop a single .ssh folder." : {
"extractionState" : "manual",
"localizations" : {
@@ -14626,23 +14537,6 @@
}
}
},
"Lifetime · Family Shared" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "永久版·家庭共享"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "永久版·家庭共享"
}
}
}
},
"Light" : {
"extractionState" : "manual",
"localizations" : {
@@ -15119,6 +15013,23 @@
}
}
},
"Make remote work easier with AI." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "AI助力,让远程操作更轻松。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "AI助力,讓遠端操作更輕鬆。"
}
}
}
},
"Manage Models" : {
"extractionState" : "manual",
"localizations" : {
@@ -18371,23 +18282,6 @@
}
}
},
"Options" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "方案"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "方案"
}
}
}
},
"Orange" : {
"extractionState" : "manual",
"localizations" : {
@@ -19143,23 +19037,6 @@
}
}
},
"Permanent Lifetime Unlock" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "永久解锁"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "永久解鎖"
}
}
}
},
"Permanently delete this conversation and its staged files?" : {
"extractionState" : "manual",
"localizations" : {
@@ -19795,23 +19672,6 @@
}
}
},
"Pro Active" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pro已激活"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pro已啟用"
}
}
}
},
"Process" : {
"extractionState" : "manual",
"localizations" : {
@@ -31106,25 +30966,6 @@
}
}
},
"Verification Required" : {
"comment" : "A label for a user that has purchased Termind Pro but has not yet verified their subscription.",
"extractionState" : "manual",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "需要验证"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "需要驗證"
}
}
}
},
"Verify host identity" : {
"extractionState" : "manual",
"localizations" : {
@@ -19,7 +19,18 @@ public nonisolated struct ProOfferPresentation: Equatable, Sendable {
formatter.unitsStyle = .full
formatter.maximumUnitCount = 1
annualBillingUnit = Self.billingUnit(billingPeriod, formatter: formatter, localize: localize)
let duration = canStartTrial ? trialDuration.flatMap { formatter.string(from: $0) } : nil
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 {
@@ -0,0 +1,287 @@
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(AppStrings.Pro.metricsFeature, symbol: "waveform.path.ecg")
Divider().padding(.leading, 52)
feature(AppStrings.Pro.terminalUploadFeature, symbol: "arrow.up.doc")
Divider().padding(.leading, 52)
feature(
presentation.supportsMCPServer
? AppStrings.Pro.connectMcpToolsAndRunTheBuiltInMcpServer : AppStrings.Pro.connectRemoteMcpTools,
symbol: "server.rack")
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)
}
}
@@ -0,0 +1,137 @@
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
}
}
}
@@ -6,10 +6,6 @@ extension AppStrings {
"Annual"
}
public static var annualAccessUntil: LocalizedStringResource {
"Annual Access Until"
}
public static var assistantFeature: LocalizedStringResource {
"Use the AI Assistant"
}
@@ -46,12 +42,8 @@ extension AppStrings {
"One-time purchase"
}
public static var planExplanation: LocalizedStringResource {
"Choose annual access or unlock Pro permanently."
}
public static var proActive: LocalizedStringResource {
"Pro Active"
public static var workflowTagline: LocalizedStringResource {
"Make remote work easier with AI."
}
public static var reloadAppStorePrices: LocalizedStringResource {
@@ -8,10 +8,6 @@ extension AppStrings {
"Abilities"
}
public static var access: LocalizedStringResource {
"Access"
}
public static var accessEnds: LocalizedStringResource {
"Access Ends"
}
@@ -92,10 +88,6 @@ extension AppStrings {
"Appearance"
}
public static var appleFamilySharing: LocalizedStringResource {
"Apple Family Sharing"
}
public static var approve: LocalizedStringResource {
"Approve"
}
@@ -428,10 +420,6 @@ extension AppStrings {
"libssh2"
}
public static var lifetimeFamilyShared: LocalizedStringResource {
"Lifetime · Family Shared"
}
public static var light: LocalizedStringResource {
"Light"
}
@@ -588,18 +576,10 @@ extension AppStrings {
"OpenSSL"
}
public static var options: LocalizedStringResource {
"Options"
}
public static var pasteOnRightClick: LocalizedStringResource {
"Paste on Right-Click"
}
public static var permanentLifetimeUnlock: LocalizedStringResource {
"Permanent Lifetime Unlock"
}
public static var permanentlyDeletesThisProviderItsKeyAndItsModels: LocalizedStringResource {
"Permanently deletes this provider, its key, and its models."
}
@@ -808,10 +788,6 @@ extension AppStrings {
"URL"
}
public static var verificationRequired: LocalizedStringResource {
"Verification Required"
}
public static var version: LocalizedStringResource {
"Version"
}
@@ -844,10 +820,6 @@ extension AppStrings {
"Add a URL first. Models are listed from the endpoint's own address."
}
public static var proPlanExplanation: LocalizedStringResource {
"Choose annual access or a permanent unlock for the AI Assistant, terminal uploads, MCP, and iCloud sync."
}
public static var headerStorageWarning: LocalizedStringResource {
"Custom headers are stored as plain text. For secrets, use the bearer token field above."
}
+22 -262
View File
@@ -7,29 +7,24 @@ import TermindPresentation
struct MobileProView: View {
@Bindable var pro: ProAccessController
@Environment(\.dismiss) private var dismiss
@Environment(\.locale) private var locale
@State private var isManagingSubscriptions = false
@State private var selectedPlan: ProPurchasePlan = .annual
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
hero
accessStatus
featureList
secondaryActions
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 20)
}
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))
.safeAreaInset(edge: .bottom, spacing: 0) { purchaseActions }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(AppStrings.Common.close.swiftUIKey, systemImage: "xmark") { dismiss() }.labelStyle(
.iconOnly)
Button(AppStrings.Common.close.swiftUIKey, systemImage: "xmark") { dismiss() }
.labelStyle(.iconOnly)
}
}
}
@@ -41,256 +36,21 @@ struct MobileProView: View {
.manageSubscriptionsSheet(isPresented: $isManagingSubscriptions)
}
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.planExplanation)
.font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
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)
}
@ViewBuilder private var accessStatus: some View {
switch pro.status {
case .loading:
statusCard(AppStrings.Pro.checkingProgress, symbol: "arrow.triangle.2.circlepath", tint: .secondary)
case .free:
statusCard(AppStrings.Pro.free, symbol: "person.crop.circle", tint: .secondary)
case .subscription(let subscription):
subscriptionStatusCard(subscription)
case .purchased(.purchased):
statusCard(AppStrings.Pro.lifetimeAccess, symbol: "checkmark.seal.fill", tint: .green)
case .purchased(.familyShared):
statusCard(AppStrings.Pro.lifetimeAccessFamilyShared, symbol: "person.3.fill", tint: .green)
case .verificationFailed:
statusCard(
AppStrings.Pro.theAppStoreCouldNotVerifyThisPurchase,
symbol: "exclamationmark.triangle.fill", tint: .orange)
case .unrestrictedForTesting:
statusCard(AppStrings.Pro.proActive, symbol: "checkmark.seal.fill", tint: .green)
}
}
private var featureList: some View {
VStack(spacing: 0) {
feature(AppStrings.Pro.assistantFeature, symbol: "sparkles")
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.connectRemoteMcpTools, symbol: "server.rack")
Divider().padding(.leading, 52)
feature(AppStrings.Pro.syncFeature, symbol: "icloud")
}
.background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
.overlay {
RoundedRectangle(cornerRadius: 16).strokeBorder(.quaternary, lineWidth: 1)
}
}
@ViewBuilder private var purchaseActions: some View {
if showsPurchaseActions {
VStack(spacing: 8) {
if pro.isProcessing { ProgressView(AppStrings.Pro.contactingTheAppStoreProgress.swiftUIKey) }
if let message = pro.message {
Text(message).font(.caption).foregroundStyle(.orange).multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
if pro.status.canPurchase {
let offer = ProOfferPresentation(
plan: selectedPlan, annualPrice: pro.annualDisplayPrice,
billingPeriod: pro.annualBillingPeriod, trialDuration: pro.annualIntroOfferDuration,
canStartTrial: pro.canStartTrial, locale: locale)
planPicker(offer)
Button(action: purchaseSelectedPlan) {
Text(offer.purchaseButtonTitle).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent).controlSize(.large)
.disabled(!pro.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(.ultraThinMaterial)
}
}
private var secondaryActions: some View {
VStack(spacing: 10) {
if canManageSubscription {
Button(AppStrings.Pro.manageSubscription.swiftUIKey) { isManagingSubscriptions = true }
.buttonStyle(.plain).foregroundStyle(.tint)
}
Button(AppStrings.Pro.restorePurchase.swiftUIKey) { Task { await pro.restorePurchases() } }
.buttonStyle(.plain).foregroundStyle(.tint).disabled(pro.isProcessing)
HStack(spacing: 16) {
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/")!)
}
.font(.caption2)
Text(AppStrings.Pro.familySharingDisclosure)
.font(.caption2).foregroundStyle(.tertiary).multilineTextAlignment(.center)
}
}
private var showsPurchaseActions: Bool {
pro.isProcessing || pro.message != nil || pro.status.canPurchase
}
private func planPicker(_ offer: ProOfferPresentation) -> some View {
HStack(spacing: 8) {
planCard(
.annual,
title: AppStrings.Pro.annual,
price: pro.annualDisplayPrice,
detail: offer.annualBillingUnit.map { String(localized: AppStrings.Pro.billingPeriod($0)) },
badge: offer.trialBadge)
planCard(
.lifetime,
title: AppStrings.Pro.lifetime,
price: pro.displayPrice,
detail: String(localized: AppStrings.Pro.oneTimePurchase),
badge: nil)
}
}
private func planCard(
_ plan: ProPurchasePlan,
title: LocalizedStringResource,
price: String?,
detail: String?,
badge: String?
) -> some View {
let isSelected = selectedPlan == plan
let isAvailable = pro.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)
.lineLimit(1)
}
}
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))
.clipShape(.rect(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
isSelected ? Color.accentColor : Color.secondary.opacity(0.2),
lineWidth: isSelected ? 2 : 1)
private func purchase(_ plan: ProPurchasePlan) {
Task {
switch plan {
case .annual: await pro.purchaseAnnual()
case .lifetime: await pro.purchaseLifetime()
}
}
.buttonStyle(.plain)
.focusEffectDisabled()
.disabled(!isAvailable)
.opacity(isAvailable ? 1 : 0.55)
}
private func purchaseSelectedPlan() {
switch selectedPlan {
case .annual:
Task { await pro.purchaseAnnual() }
case .lifetime:
Task { await pro.purchaseLifetime() }
}
}
private var canManageSubscription: Bool {
guard case .subscription(let subscription) = pro.status else { return false }
return subscription.ownership == .purchased
}
@ViewBuilder
private func subscriptionStatusCard(_ subscription: ProSubscriptionStatus) -> some View {
let detail = subscription.expiresAt.map {
Text($0, format: .dateTime.year().month().day().hour().minute())
}
switch subscription.phase {
case .introductoryTrial:
statusCard(AppStrings.Pro.freeTrial, symbol: "clock.fill", tint: .orange, detail: detail)
case .subscribed:
statusCard(
subscription.willAutoRenew == false
? AppStrings.Pro.annualAccessUntil : AppStrings.Pro.annualSubscription,
symbol: "checkmark.seal.fill", tint: .green, detail: detail)
case .gracePeriod:
statusCard(
AppStrings.Pro.billingGracePeriod, symbol: "clock.badge.exclamationmark", tint: .orange, detail: detail
)
case .billingRetry:
statusCard(
AppStrings.Pro.billingIssue, symbol: "creditcard.trianglebadge.exclamationmark", tint: .orange,
detail: detail)
case .expired:
statusCard(
AppStrings.Pro.subscriptionExpired, symbol: "calendar.badge.exclamationmark", tint: .orange,
detail: detail)
case .revoked:
statusCard(AppStrings.Pro.subscriptionRevoked, symbol: "xmark.seal", tint: .red, detail: detail)
}
}
private func statusCard(
_ title: LocalizedStringResource,
symbol: String,
tint: Color,
detail: Text? = nil
) -> some View {
HStack(spacing: 12) {
Image(systemName: 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: 2) {
Text(title).font(.headline)
if let detail { detail.font(.caption).foregroundStyle(.secondary) }
}
Spacer(minLength: 0)
}
.padding(14)
.background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 14))
.overlay { RoundedRectangle(cornerRadius: 14).strokeBorder(tint.opacity(0.22), lineWidth: 1) }
.accessibilityElement(children: .combine)
}
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)
}
}
+85
View File
@@ -0,0 +1,85 @@
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)
}
}
+36 -180
View File
@@ -7,207 +7,63 @@ import TermindPresentation
struct ProPaywallView: View {
@Environment(AppModel.self) private var app
@Environment(\.dismiss) private var dismiss
@Environment(\.locale) private var locale
@Environment(\.openURL) private var openURL
@State private var selectedPlan: ProPurchasePlan = .annual
private var pro: ProAccessController { app.proAccess }
var body: some View {
VStack(spacing: 0) {
VStack(spacing: 14) {
Image(systemName: "sparkles.rectangle.stack.fill")
.font(.system(size: 40, weight: .medium))
.foregroundStyle(.tint)
VStack(spacing: Metrics.Space.s) {
Text(AppStrings.Pro.termindPro).font(.largeTitle.bold())
Text(AppStrings.Pro.planExplanation)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
.padding(.top, 24)
VStack(alignment: .leading, spacing: Metrics.Space.m) {
feature(AppStrings.Pro.assistantFeature, "sparkles")
feature(AppStrings.Pro.metricsFeature, "waveform.path.ecg")
feature(AppStrings.Pro.terminalUploadFeature, "arrow.up.doc")
feature(AppStrings.Pro.connectMcpToolsAndRunTheBuiltInMcpServer, "server.rack")
feature(AppStrings.Pro.syncFeature, "icloud")
}
.frame(maxWidth: 460)
.padding(.vertical, 20)
if pro.requestedFeature == .unlimitedMetrics {
Text(AppStrings.Pro.freeMetricsLimit)
.font(.callout).foregroundStyle(.secondary)
.multilineTextAlignment(.center).padding(.horizontal, 24).padding(.bottom, 12)
}
if pro.isProcessing {
ProgressView(AppStrings.Pro.contactingTheAppStoreProgress.swiftUIKey).padding(.bottom, 12)
}
if let message = pro.message {
Text(message).font(.caption).foregroundStyle(.orange)
.multilineTextAlignment(.center).padding(.bottom, 12)
}
VStack(spacing: 8) {
if pro.status.canPurchase {
let offer = ProOfferPresentation(
plan: selectedPlan, annualPrice: pro.annualDisplayPrice,
billingPeriod: pro.annualBillingPeriod, trialDuration: pro.annualIntroOfferDuration,
canStartTrial: pro.canStartTrial, locale: locale
) { AppLocalization.string($0, locale: locale) }
planPicker(offer)
Button(action: purchaseSelectedPlan) {
Text(offer.purchaseButtonTitle).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(maxWidth: 420)
.disabled(!canPurchaseSelectedPlan)
if let detail = offer.selectedPlanDetail {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: 420)
}
}
if canManageSubscription {
Link(
AppStrings.Pro.manageSubscription.swiftUIKey,
destination: URL(string: "https://apps.apple.com/account/subscriptions")!
)
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
Button(AppStrings.Pro.restorePurchase.swiftUIKey) { Task { await pro.restorePurchases() } }
.buttonStyle(.link)
.disabled(pro.isProcessing)
if pro.annualProduct == nil || pro.product == nil {
Button(AppStrings.Pro.reloadAppStorePrices.swiftUIKey) { Task { await pro.reloadProducts() } }
.buttonStyle(.link)
.disabled(pro.isProcessing)
}
HStack(spacing: Metrics.Space.m) {
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/")!)
}
.font(.caption2)
Text(AppStrings.Pro.familySharingDisclosure)
.font(.caption2).foregroundStyle(.tertiary)
}
.padding(.bottom, 20)
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)
}
.frame(width: 540)
.padding(.horizontal, 32)
.task {
await pro.reloadProducts()
if pro.annualProduct == nil, pro.product != nil { selectedPlan = .lifetime }
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(AppStrings.Common.close.swiftUIKey) { dismiss() }
}
}
}
private func planPicker(_ offer: ProOfferPresentation) -> some View {
HStack(spacing: Metrics.Space.s) {
planCard(
.annual,
title: AppStrings.Pro.annual,
price: pro.annualDisplayPrice,
detail: offer.annualBillingUnit.map {
AppLocalization.resolve(AppStrings.Pro.billingPeriod($0), locale: locale)
},
badge: offer.trialBadge)
planCard(
.lifetime,
title: AppStrings.Pro.lifetime,
price: pro.displayPrice,
detail: AppLocalization.resolve(AppStrings.Pro.oneTimePurchase, locale: locale),
badge: nil)
}
.frame(maxWidth: 420)
}
private func planCard(
_ plan: ProPurchasePlan,
title: LocalizedStringResource,
price: String?,
detail: String?,
badge: String?
) -> some View {
let isSelected = selectedPlan == plan
let isAvailable = pro.canPurchase(plan)
return Button {
selectedPlan = plan
private var closeButton: some View {
Button {
dismiss()
} label: {
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
HStack {
Text(title).font(.headline)
Spacer(minLength: 4)
if let badge {
Text(badge)
.font(.caption2.weight(.semibold))
.foregroundStyle(.tint)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(.tint.opacity(0.12), in: .capsule)
}
}
Text(price ?? "—")
.font(.title2.bold())
.foregroundStyle(.primary)
if let detail {
Text(detail).font(.caption).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, minHeight: 62, alignment: .leading)
.padding(12)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.secondary.opacity(0.06))
.clipShape(.rect(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
isSelected ? Color.accentColor : Color.secondary.opacity(0.2),
lineWidth: isSelected ? 2 : 1)
}
Label(AppStrings.Common.close, systemImage: "xmark")
.labelStyle(.iconOnly)
.frame(width: 30, height: 30)
.contentShape(.circle)
}
.buttonStyle(.plain)
.focusEffectDisabled()
.disabled(!isAvailable)
.opacity(isAvailable ? 1 : 0.55)
.termindGlassCircle(isInteractive: true)
.termindInputAwareFocusEffect()
.keyboardShortcut(.cancelAction)
.help(AppStrings.Common.close)
.accessibilityLabel(Text(AppStrings.Common.close))
}
private var canPurchaseSelectedPlan: Bool {
pro.canPurchase(selectedPlan)
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 purchaseSelectedPlan() {
switch selectedPlan {
case .annual:
Task { await pro.purchaseAnnual() }
case .lifetime:
Task { await pro.purchaseLifetime() }
private func purchase(_ plan: ProPurchasePlan) {
Task {
switch plan {
case .annual: await pro.purchaseAnnual()
case .lifetime: await pro.purchaseLifetime()
}
}
}
private var canManageSubscription: Bool {
guard case .subscription(let subscription) = pro.status else { return false }
return subscription.ownership == .purchased
}
private func feature(_ title: LocalizedStringResource, _ symbol: String) -> some View {
Label(title, systemImage: symbol).font(.body)
}
}
struct ProPaywallPresenter: ViewModifier {
+11 -129
View File
@@ -8,7 +8,7 @@ import TermindPresentation
import struct TermindDomain.Host
nonisolated enum SettingsCategory: String, CaseIterable, Identifiable, Sendable {
case pro, app, terminal, connections, ai, sync
case app, terminal, connections, ai, sync
#if DEBUG
case developer
#endif
@@ -17,7 +17,6 @@ nonisolated enum SettingsCategory: String, CaseIterable, Identifiable, Sendable
var title: LocalizedStringResource {
switch self {
case .pro: AppStrings.Pro.termindPro
case .app: AppStrings.Settings.app
case .terminal: AppStrings.Settings.terminal
case .connections: AppStrings.Settings.connections
@@ -31,7 +30,6 @@ nonisolated enum SettingsCategory: String, CaseIterable, Identifiable, Sendable
func title(locale: Locale = .current) -> String {
switch self {
case .pro: AppLocalization.resolve(AppStrings.Pro.termindPro, locale: locale)
case .app: AppLocalization.resolve(AppStrings.Settings.app, locale: locale)
case .terminal: AppLocalization.resolve(AppStrings.Settings.terminal, locale: locale)
case .connections: AppLocalization.resolve(AppStrings.Settings.connections, locale: locale)
@@ -45,7 +43,6 @@ nonisolated enum SettingsCategory: String, CaseIterable, Identifiable, Sendable
var symbol: String {
switch self {
case .pro: "sparkles.rectangle.stack"
case .app: "app.badge"
case .terminal: "terminal"
case .connections: MenuSymbol.connect
@@ -117,7 +114,8 @@ struct SettingsPageView: View {
SettingsIndexPage(
categories: SettingsCategory.allCases,
select: { navigate(to: .category($0)) },
openLogging: { navigate(to: .logging) }
openLogging: { navigate(to: .logging) },
openPro: { app.proAccess.presentPaywall() }
)
case .category(let category):
categoryPage(category)
@@ -153,8 +151,6 @@ struct SettingsPageView: View {
@ViewBuilder
private func categoryPage(_ category: SettingsCategory) -> some View {
switch category {
case .pro:
SettingsFormPage { ProSettings() }
case .app:
SettingsFormPage {
AppSettings()
@@ -239,11 +235,18 @@ private struct SettingsIndexPage: View {
let categories: [SettingsCategory]
let select: (SettingsCategory) -> Void
let openLogging: () -> Void
let openPro: () -> Void
var body: some View {
Form {
Section {
categoryButton(.pro)
Button(action: openPro) {
Label(AppStrings.Pro.termindPro.swiftUIKey, systemImage: "sparkles.rectangle.stack")
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityIdentifier("settings.openPro")
}
Section {
@@ -283,8 +286,6 @@ private struct SettingsIndexPage: View {
private var regularCategories: [SettingsCategory] {
categories.filter { category in
switch category {
case .pro:
false
#if DEBUG
case .developer:
false
@@ -508,125 +509,6 @@ private struct AppInfoSettings: View {
}
}
private struct ProSettings: View {
@Environment(AppModel.self) private var app
var body: some View {
Group {
Section(AppStrings.Settings.access.swiftUIKey) {
LabeledContent(AppStrings.Common.status.swiftUIKey) { statusLabel }
if let expirationLabel, let expirationDate {
LabeledContent(expirationLabel.swiftUIKey) {
Text(expirationDate, format: .dateTime.year().month().day().hour().minute())
}
}
}
Section(AppStrings.Pro.termindPro.swiftUIKey) {
Text(
AppStrings.Settings.proPlanExplanation
)
.foregroundStyle(.secondary)
if app.proAccess.status.canPurchase {
Button(AppStrings.Settings.viewTermindPro.swiftUIKey) { app.proAccess.presentPaywall() }
.buttonStyle(.borderedProminent)
}
if canManageSubscription {
Link(
AppStrings.Pro.manageSubscription.swiftUIKey,
destination: URL(string: "https://apps.apple.com/account/subscriptions")!)
}
Button(AppStrings.Pro.restorePurchase.swiftUIKey) { Task { await app.proAccess.restorePurchases() } }
.disabled(app.proAccess.isProcessing)
if let message = app.proAccess.message {
Text(message).font(.caption).foregroundStyle(.orange)
}
}
Section(AppStrings.Settings.options.swiftUIKey) {
includedFeature(AppStrings.Pro.annualSubscription, systemImage: "calendar")
includedFeature(AppStrings.Settings.permanentLifetimeUnlock, systemImage: "infinity")
includedFeature(AppStrings.Settings.appleFamilySharing, systemImage: "person.3")
Text(AppStrings.Settings.aiProviderUsageAndChargesAreSeparateFromTermindPro)
.font(.caption).foregroundStyle(.secondary)
}
}
}
private var canManageSubscription: Bool {
guard case .subscription(let subscription) = app.proAccess.status else { return false }
return subscription.ownership == .purchased
}
private var expirationDate: Date? {
switch app.proAccess.status {
case .subscription(let subscription): subscription.expiresAt
default: nil
}
}
private var expirationLabel: LocalizedStringResource? {
switch app.proAccess.status {
case .subscription(let subscription):
switch subscription.phase {
case .introductoryTrial: AppStrings.Settings.trialEnds
case .subscribed:
subscription.willAutoRenew == false ? AppStrings.Settings.accessEnds : AppStrings.Settings.renews
case .gracePeriod: AppStrings.Settings.gracePeriodEnds
case .billingRetry, .expired, .revoked: AppStrings.Settings.expired
}
default:
nil
}
}
private func includedFeature(
_ title: LocalizedStringResource,
systemImage: String
) -> some View {
HStack(spacing: Metrics.Space.s) {
Image(systemName: systemImage)
.frame(width: 20, alignment: .center)
Text(title)
}
}
@ViewBuilder
private var statusLabel: some View {
switch app.proAccess.status {
case .loading: Label(AppStrings.Pro.checkingProgress.swiftUIKey, systemImage: "arrow.triangle.2.circlepath")
case .free: Label(AppStrings.Pro.free.swiftUIKey, systemImage: "person.crop.circle")
case .subscription(let subscription): subscriptionStatusLabel(subscription)
case .purchased(.purchased): Label(AppStrings.Pro.lifetime.swiftUIKey, systemImage: "infinity")
case .purchased(.familyShared):
Label(AppStrings.Settings.lifetimeFamilyShared.swiftUIKey, systemImage: "person.3.fill")
case .verificationFailed:
Label(AppStrings.Settings.verificationRequired.swiftUIKey, systemImage: "exclamationmark.shield")
case .unrestrictedForTesting: Label(AppStrings.Settings.developmentAccess.swiftUIKey, systemImage: "hammer")
}
}
@ViewBuilder
private func subscriptionStatusLabel(_ subscription: ProSubscriptionStatus) -> some View {
switch subscription.phase {
case .introductoryTrial: Label(AppStrings.Pro.freeTrial.swiftUIKey, systemImage: "clock")
case .subscribed:
Label(
subscription.ownership == .familyShared
? AppStrings.Settings.annualFamilyShared.swiftUIKey
: AppStrings.Pro.annualSubscription.swiftUIKey,
systemImage: subscription.ownership == .familyShared ? "person.3.fill" : "checkmark.seal.fill")
case .gracePeriod:
Label(AppStrings.Pro.billingGracePeriod.swiftUIKey, systemImage: "clock.badge.exclamationmark")
case .billingRetry:
Label(AppStrings.Pro.billingIssue.swiftUIKey, systemImage: "creditcard.trianglebadge.exclamationmark")
case .expired:
Label(AppStrings.Pro.subscriptionExpired.swiftUIKey, systemImage: "calendar.badge.exclamationmark")
case .revoked: Label(AppStrings.Pro.subscriptionRevoked.swiftUIKey, systemImage: "xmark.seal")
}
}
}
private struct StartupSettings: View {
@Environment(\.appearsActive) private var appearsActive
@State private var loginItem = LoginItemController()
+2 -2
View File
@@ -107,12 +107,12 @@ struct AppLanguageTests {
let price = "$12.34"
let offer = ProOfferPresentation(
plan: plan, annualPrice: price, billingPeriod: DateComponents(year: 1),
trialDuration: DateComponents(day: 7), canStartTrial: true, locale: locale
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)).isEmpty)
#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 {
+1 -1
View File
@@ -184,7 +184,7 @@ struct ArchitectureBoundaryTests {
in: "../Frameworks/TermindPresentation",
forbidden: [
"Termind", "TermindIOS", "UIKit", "AppKit", "SwiftData", "GRDB", "TermindPersistence",
"TermindRemoteInfrastructure",
"TermindRemoteInfrastructure", "TermindInfrastructure", "StoreKit",
])
}
+67
View File
@@ -0,0 +1,67 @@
import AppKit
import SwiftUI
import TermindDomain
import TermindPresentation
import Testing
/// Offscreen fixtures exercise shared content without contacting StoreKit or activating a window.
@Suite(.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: 604, height: 640)]
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,
supportsMCPServer: true)
let content = ProPaywallContent(
presentation: presentation, selectedPlan: .constant(.annual),
background: Color(nsColor: .windowBackgroundColor),
cardBackground: Color(nsColor: .controlBackgroundColor), purchase: { _ in },
restorePurchases: {}, reloadProducts: {}, manageSubscription: {}
)
.environment(\.locale, Locale(identifier: language))
.environment(\.dynamicTypeSize, largeText ? .accessibility3 : .large)
.frame(width: size.width, height: size.height)
.background(.background)
let name = "pro-\(language)-\(Int(size.width))-\(largeText ? "large" : "regular")-\(subscribed).png"
let host = NSHostingView(rootView: content)
let window = NSWindow(
contentRect: CGRect(origin: .zero, size: size), styleMask: .borderless,
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentView = host
defer { window.close() }
host.frame = CGRect(origin: .zero, size: size)
host.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(30))
host.layoutSubtreeIfNeeded()
let scroll = try #require(descendants(host).compactMap { $0 as? NSScrollView }.first)
let document = try #require(scroll.documentView)
#expect(document.frame.width <= scroll.contentView.bounds.width + 1)
#expect(scroll.contentView.bounds.height > 100)
let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds))
host.cacheDisplay(in: host.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
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: NSView) -> [NSView] {
[view] + view.subviews.flatMap(descendants)
}
}
+27
View File
@@ -11,6 +11,33 @@ import Testing
@testable import Termind
struct SettingsNavigationTests {
@Test(
arguments: [
EntitlementStatus.free(trialAvailable: true), .purchased(.purchased), .purchased(.familyShared),
.subscription(
ProSubscriptionStatus(
phase: .subscribed, expiresAt: .distantFuture, willAutoRenew: true, ownership: .purchased)),
]) @MainActor
func proPresentationLeavesSettingsNavigationAndReturnDestinationIntact(status: EntitlementStatus) {
let workspace = Workspace(pane: Pane(.empty))
let window = WindowModel(workspaces: [workspace])
let pro = ProAccessController.testing(status: status)
window.openSettings()
pro.presentPaywall()
#expect(pro.isPaywallPresented)
#expect(window.settingsRoute == .index)
#expect(window.selectedDestination == .settings)
#expect(SettingsCategory(rawValue: "pro") == nil)
pro.isPaywallPresented = false
window.settingsRoute = .category(.terminal)
window.navigateBackInSettings()
#expect(window.settingsRoute == .index)
window.navigateBackInSettings()
#expect(window.selectedDestination == .workspace(workspace.id))
}
@Test @MainActor
func legacySearchSettingsAndSecretAreRemoved() throws {
let suite = "LegacyWebSearchSettingsTests.\(UUID().uuidString)"