Files
Termind/TFI/Features/Commerce/MobileProView.swift
T
2026-09-17 21:22:21 +08:00

291 lines
12 KiB
Swift

import StoreKit
import SwiftUI
import TermindDomain
import TermindInfrastructure
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)
}
.background(Color(uiColor: .systemGroupedBackground))
.safeAreaInset(edge: .bottom, spacing: 0) { purchaseActions }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close", systemImage: "xmark") { dismiss() }.labelStyle(.iconOnly)
}
}
}
.mobileSheetPresentation()
.task {
await pro.reloadProducts()
if pro.annualProduct == nil, pro.product != nil { selectedPlan = .lifetime }
}
.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("Termind Pro").font(.largeTitle.bold())
Text("Choose annual access or unlock Pro permanently.")
.font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
@ViewBuilder private var accessStatus: some View {
switch pro.status {
case .loading:
statusCard("Checking…", symbol: "arrow.triangle.2.circlepath", tint: .secondary)
case .free:
statusCard("Free", symbol: "person.crop.circle", tint: .secondary)
case .subscription(let subscription):
subscriptionStatusCard(subscription)
case .purchased(.purchased):
statusCard("Lifetime Access", symbol: "checkmark.seal.fill", tint: .green)
case .purchased(.familyShared):
statusCard("Lifetime Access · Family Shared", symbol: "person.3.fill", tint: .green)
case .verificationFailed:
statusCard(
"The App Store could not verify this purchase.",
symbol: "exclamationmark.triangle.fill", tint: .orange)
case .unrestrictedForTesting:
statusCard("Pro Active", symbol: "checkmark.seal.fill", tint: .green)
}
}
private var featureList: some View {
VStack(spacing: 0) {
feature("Use the AI Assistant", symbol: "sparkles")
Divider().padding(.leading, 52)
feature("Monitor more than 3 servers", symbol: "waveform.path.ecg")
Divider().padding(.leading, 52)
feature("Upload files by dropping them on a terminal", symbol: "arrow.up.doc")
Divider().padding(.leading, 52)
feature("Connect remote MCP tools", symbol: "server.rack")
Divider().padding(.leading, 52)
feature("Sync Vault, Metrics, and Settings across devices", 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("Contacting the App Store…") }
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("Manage Subscription") { isManagingSubscriptions = true }
.buttonStyle(.plain).foregroundStyle(.tint)
}
Button("Restore Purchase") { Task { await pro.restorePurchases() } }
.buttonStyle(.plain).foregroundStyle(.tint).disabled(pro.isProcessing)
HStack(spacing: 16) {
Link("Privacy Policy", destination: URL(string: "https://termind.akinokaede.com/privacy/")!)
Link(
"Terms of Use",
destination: URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")!)
}
.font(.caption2)
Text("Apple Family Sharing supported · AI provider charges are separate")
.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: "Annual",
price: pro.annualDisplayPrice,
detail: offer.annualBillingUnit.map { String(localized: "per \($0)") },
badge: offer.trialBadge)
planCard(
.lifetime,
title: "Lifetime",
price: pro.displayPrice,
detail: String(localized: "One-time purchase"),
badge: nil)
}
}
private func planCard(
_ plan: ProPurchasePlan,
title: LocalizedStringKey,
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)
}
}
.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("Free Trial", symbol: "clock.fill", tint: .orange, detail: detail)
case .subscribed:
statusCard(
subscription.willAutoRenew == false ? "Annual Access Until" : "Annual Subscription",
symbol: "checkmark.seal.fill", tint: .green, detail: detail)
case .gracePeriod:
statusCard("Billing Grace Period", symbol: "clock.badge.exclamationmark", tint: .orange, detail: detail)
case .billingRetry:
statusCard(
"Billing Issue", symbol: "creditcard.trianglebadge.exclamationmark", tint: .orange,
detail: detail)
case .expired:
statusCard(
"Subscription Expired", symbol: "calendar.badge.exclamationmark", tint: .orange,
detail: detail)
case .revoked:
statusCard("Subscription Revoked", symbol: "xmark.seal", tint: .red, detail: detail)
}
}
private func statusCard(
_ title: LocalizedStringKey,
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: LocalizedStringKey, 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)
}
}