diff --git a/FrameworkTests/ProOfferPresentationTests.swift b/FrameworkTests/ProOfferPresentationTests.swift index 2faedf9e..b553a9bc 100644 --- a/FrameworkTests/ProOfferPresentationTests.swift +++ b/FrameworkTests/ProOfferPresentationTests.swift @@ -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() diff --git a/FrameworkTests/ProPaywallPresentationTests.swift b/FrameworkTests/ProPaywallPresentationTests.swift new file mode 100644 index 00000000..728ce6ed --- /dev/null +++ b/FrameworkTests/ProPaywallPresentationTests.swift @@ -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)) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index c544903d..c90d57c9 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Frameworks/TermindPresentation/ProOfferPresentation.swift b/Frameworks/TermindPresentation/ProOfferPresentation.swift index ffd8cad4..66f76a4c 100644 --- a/Frameworks/TermindPresentation/ProOfferPresentation.swift +++ b/Frameworks/TermindPresentation/ProOfferPresentation.swift @@ -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 { diff --git a/Frameworks/TermindPresentation/ProPaywallContent.swift b/Frameworks/TermindPresentation/ProPaywallContent.swift new file mode 100644 index 00000000..3691d042 --- /dev/null +++ b/Frameworks/TermindPresentation/ProPaywallContent.swift @@ -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, + 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) + } +} diff --git a/Frameworks/TermindPresentation/ProPaywallPresentation.swift b/Frameworks/TermindPresentation/ProPaywallPresentation.swift new file mode 100644 index 00000000..e44f0b81 --- /dev/null +++ b/Frameworks/TermindPresentation/ProPaywallPresentation.swift @@ -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 + } + } +} diff --git a/Frameworks/TermindPresentation/Strings/Pro.swift b/Frameworks/TermindPresentation/Strings/Pro.swift index 2ea6129b..f58094e6 100644 --- a/Frameworks/TermindPresentation/Strings/Pro.swift +++ b/Frameworks/TermindPresentation/Strings/Pro.swift @@ -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 { diff --git a/Frameworks/TermindPresentation/Strings/Settings.swift b/Frameworks/TermindPresentation/Strings/Settings.swift index b455f86d..ce5c2f35 100644 --- a/Frameworks/TermindPresentation/Strings/Settings.swift +++ b/Frameworks/TermindPresentation/Strings/Settings.swift @@ -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." } diff --git a/TFI/Features/Commerce/MobileProView.swift b/TFI/Features/Commerce/MobileProView.swift index d798b7cd..cc593383 100644 --- a/TFI/Features/Commerce/MobileProView.swift +++ b/TFI/Features/Commerce/MobileProView.swift @@ -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) } } diff --git a/TFITests/ProPaywallRenderingTests.swift b/TFITests/ProPaywallRenderingTests.swift new file mode 100644 index 00000000..deb5384c --- /dev/null +++ b/TFITests/ProPaywallRenderingTests.swift @@ -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) + } +} diff --git a/TFM/Features/Commerce/ProPaywallView.swift b/TFM/Features/Commerce/ProPaywallView.swift index f1c6c832..4433365e 100644 --- a/TFM/Features/Commerce/ProPaywallView.swift +++ b/TFM/Features/Commerce/ProPaywallView.swift @@ -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 { diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index 7a65fc90..63d81487 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -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() diff --git a/TFMTests/AppLanguageTests.swift b/TFMTests/AppLanguageTests.swift index 7998d6a5..91784eb2 100644 --- a/TFMTests/AppLanguageTests.swift +++ b/TFMTests/AppLanguageTests.swift @@ -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 { diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 1bb1ffce..9ea315f9 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -184,7 +184,7 @@ struct ArchitectureBoundaryTests { in: "../Frameworks/TermindPresentation", forbidden: [ "Termind", "TermindIOS", "UIKit", "AppKit", "SwiftData", "GRDB", "TermindPersistence", - "TermindRemoteInfrastructure", + "TermindRemoteInfrastructure", "TermindInfrastructure", "StoreKit", ]) } diff --git a/TFMTests/ProPaywallRenderingTests.swift b/TFMTests/ProPaywallRenderingTests.swift new file mode 100644 index 00000000..11ef0d16 --- /dev/null +++ b/TFMTests/ProPaywallRenderingTests.swift @@ -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) + } +} diff --git a/TFMTests/SettingsNavigationTests.swift b/TFMTests/SettingsNavigationTests.swift index 7f3d7cf5..64bba19b 100644 --- a/TFMTests/SettingsNavigationTests.swift +++ b/TFMTests/SettingsNavigationTests.swift @@ -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)"