feat: unify Pro paywall in macOS and iOS
This commit is contained in:
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))
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -184,7 +184,7 @@ struct ArchitectureBoundaryTests {
|
||||
in: "../Frameworks/TermindPresentation",
|
||||
forbidden: [
|
||||
"Termind", "TermindIOS", "UIKit", "AppKit", "SwiftData", "GRDB", "TermindPersistence",
|
||||
"TermindRemoteInfrastructure",
|
||||
"TermindRemoteInfrastructure", "TermindInfrastructure", "StoreKit",
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
|
||||
Reference in new issue
Block a user