291 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|