chore(ios): iap paywall update (#13669)

This pull request introduces several improvements and refactors to the
iOS frontend, with a focus on the paywall system, configuration, and
developer experience. The most significant changes include dynamic
pricing updates for subscription packages, the introduction of a
centralized pricing configuration, and enhanced developer documentation
and settings for Claude Code. There are also minor fixes and
improvements to restore purchase flows, App Store syncing, and protocol
usage guidance.

**Paywall System Improvements**

* Subscription package pricing and display is now dynamically updated
based on App Store data, ensuring users see accurate, localized pricing
and descriptions. This includes new logic for calculating monthly prices
and updating package button text. (`ViewModel.swift`,
`ViewModel+Action.swift`, `SKUnit+Pro.swift`, `SKUnit+AI.swift`)
[[1]](diffhunk://#diff-cb192a424400265435cb06d86b204aa17b4e8195d9dd811580f51faeda211ff0R83-R160)
[[2]](diffhunk://#diff-cb192a424400265435cb06d86b204aa17b4e8195d9dd811580f51faeda211ff0L102-R199)
[[3]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbL58-R73)
[[4]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbL74-R94)
[[5]](diffhunk://#diff-ea535c02550f727587e74521da8fd90dec23cbe3c685f9c4aa4923ce0bbdb363L19-R35)
[[6]](diffhunk://#diff-a5fef660f959bbb52ce3f19bba8bfbd0bb00d66c9f18a20a998101b5df6c8f60L18-R22)
* Introduced a new `PricingConfiguration.swift` file to centralize
product identifiers, default selections, and display strings for
subscription products, improving maintainability and consistency.
(`PricingConfiguration.swift`, `SKUnit+Pro.swift`, `SKUnit+AI.swift`)
[[1]](diffhunk://#diff-de4566ecd5bd29f36737ae5e5904345bd1a5c8f0a73140c3ebba41856bae3e86R1-R54)
[[2]](diffhunk://#diff-ea535c02550f727587e74521da8fd90dec23cbe3c685f9c4aa4923ce0bbdb363L19-R35)
[[3]](diffhunk://#diff-a5fef660f959bbb52ce3f19bba8bfbd0bb00d66c9f18a20a998101b5df6c8f60L18-R22)

**Developer Experience and Documentation**

* Added `AGENTS.md` to provide comprehensive guidance for Claude Code
and developers, including project overview, build commands,
architecture, native bridge APIs, Swift code style, and dependencies.
(`AGENTS.md`)
* Added a local settings file (`settings.local.json`) to configure
permissions for Claude Code, allowing specific Bash commands for iOS
builds. (`settings.local.json`)
* Updated Swift architecture guidelines to discourage protocol-oriented
design unless necessary, favoring dependency injection and composition.
(`AGENTS.md`)

**User Experience Improvements**

* The purchase footer now includes an underline for "Restore Purchase"
and a clear message about subscription auto-renewal and cancellation
flexibility. (`PurchaseFooterView.swift`)
* Improved restore purchase and App Store sync logic to better handle
user sign-in prompts and error handling. (`ViewModel+Action.swift`,
`Store.swift`)
[[1]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbL45-R49)
[[2]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbL58-R73)
[[3]](diffhunk://#diff-9f18fbbf15591c56380ce46358089c663ce4440f596db8577de76dc6cd306b54R26-R28)

**Minor Fixes and Refactoring**

* Made `docId` in `DeleteSessionInput` optional to match GraphQL schema
expectations. (`DeleteSessionInput.graphql.swift`)
[[1]](diffhunk://#diff-347e5828e46f435d7d7090a3e3eb7445af8c616f663e8711cd832f385f870a9bL14-R14)
[[2]](diffhunk://#diff-347e5828e46f435d7d7090a3e3eb7445af8c616f663e8711cd832f385f870a9bL25-R25)
* Minor formatting and dependency list updates in `Package.swift`.
(`Package.swift`)
* Fixed concurrency usage in event streaming for chat manager.
(`ChatManager+Stream.swift`)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* New Features
* Paywall options now dynamically reflect product data with clearer
labels and monthly price calculations.
* Added an auto‑renewal note (“cancel anytime”) and underlined “Restore
Purchase” for better clarity.

* Refactor
* Improved purchase/restore flow reliability and UI updates for a
smoother experience.

* Documentation
* Added a comprehensive development guide and updated architecture/style
guidance for iOS.

* Chores
* Introduced local build permissions configuration for iOS development.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Lakr
2025-09-29 17:18:47 +08:00
committed by GitHub
parent 12daefdf54
commit 8df7353722
12 changed files with 294 additions and 39 deletions

View File

@@ -11,7 +11,7 @@ public struct DeleteSessionInput: InputObject {
}
public init(
docId: String,
docId: GraphQLNullable<String> = nil,
sessionIds: [String],
workspaceId: String
) {
@@ -22,7 +22,7 @@ public struct DeleteSessionInput: InputObject {
])
}
public var docId: String {
public var docId: GraphQLNullable<String> {
get { __data["docId"] }
set { __data["docId"] = newValue }
}

View File

@@ -21,7 +21,7 @@ let package = Package(
targets: [
.target(
name: "AffinePaywall",
dependencies: ["AffineResources"],
dependencies: ["AffineResources"]
),
]
)

View File

@@ -63,6 +63,7 @@ struct PurchaseFooterView: View {
Text("Already Purchased")
} else {
Text("Restore Purchase")
.underline()
}
}
.font(.system(size: 12))
@@ -70,6 +71,12 @@ struct PurchaseFooterView: View {
.foregroundStyle(AffineColors.textSecondary.color)
.opacity(viewModel.products.isEmpty ? 0 : 1)
.disabled(isPurchased)
Text("The Monthly and Annual plans renew automatically, but youre free to cancel at any time if its not right for you.")
.font(.system(size: 12))
.foregroundStyle(AffineColors.textSecondary.color)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.animation(.spring, value: viewModel.updating)
}

View File

@@ -0,0 +1,54 @@
//
// PricingConfiguration.swift
// AffinePaywall
//
// Created by Claude Code on 9/29/25.
//
import Foundation
enum PricingConfiguration {
static let proMonthly = ProductConfiguration(
productIdentifier: "app.affine.pro.Monthly",
revenueCatIdentifier: "app.affine.pro.Monthly",
description: "Monthly",
isDefaultSelected: false
)
static let proAnnual = ProductConfiguration(
productIdentifier: "app.affine.pro.Annual",
revenueCatIdentifier: "app.affine.pro.Annual",
description: "Annual",
badge: "Save 15%",
isDefaultSelected: true
)
static let aiAnnual = ProductConfiguration(
productIdentifier: "app.affine.pro.ai.Annual",
revenueCatIdentifier: "app.affine.pro.ai.Annual",
description: "",
isDefaultSelected: true
)
}
struct ProductConfiguration {
let productIdentifier: String
let revenueCatIdentifier: String
let description: String
let badge: String?
let isDefaultSelected: Bool
init(
productIdentifier: String,
revenueCatIdentifier: String,
description: String,
badge: String? = nil,
isDefaultSelected: Bool = false
) {
self.productIdentifier = productIdentifier
self.revenueCatIdentifier = revenueCatIdentifier
self.description = description
self.badge = badge
self.isDefaultSelected = isDefaultSelected
}
}

View File

@@ -15,11 +15,11 @@ extension SKUnit {
secondaryText: "A true multimodal AI copilot.",
package: [
SKUnitPackageOption(
price: "$8.9 per month",
price: "...", // Will be populated from App Store
description: "",
isDefaultSelected: true,
primaryTitle: "$8.9 per month",
secondaryTitle: "billed annually",
primaryTitle: "...", // Will be populated from App Store
secondaryTitle: "",
productIdentifier: "app.affine.pro.ai.Annual",
revenueCatIdentifier: "app.affine.pro.ai.Annual"
),

View File

@@ -16,23 +16,23 @@ extension SKUnit {
secondaryText: "For family and small teams.",
package: [
SKUnitPackageOption(
price: "$7.99",
description: "Monthly",
isDefaultSelected: false,
primaryTitle: "Upgrade for $7.99/month",
price: "...", // Will be populated from App Store
description: PricingConfiguration.proMonthly.description,
isDefaultSelected: PricingConfiguration.proMonthly.isDefaultSelected,
primaryTitle: "...", // Will be populated from App Store
secondaryTitle: "",
productIdentifier: "app.affine.pro.Monthly",
revenueCatIdentifier: "app.affine.pro.Monthly"
productIdentifier: PricingConfiguration.proMonthly.productIdentifier,
revenueCatIdentifier: PricingConfiguration.proMonthly.revenueCatIdentifier
),
SKUnitPackageOption(
price: "$6.75",
description: "Annual",
badge: "Save 15%",
isDefaultSelected: true,
primaryTitle: "Upgrade for $6.75/month",
price: "...", // Will be populated from App Store
description: PricingConfiguration.proAnnual.description,
badge: PricingConfiguration.proAnnual.badge,
isDefaultSelected: PricingConfiguration.proAnnual.isDefaultSelected,
primaryTitle: "...", // Will be populated from App Store
secondaryTitle: "",
productIdentifier: "app.affine.pro.Annual",
revenueCatIdentifier: "app.affine.pro.Annual"
productIdentifier: PricingConfiguration.proAnnual.productIdentifier,
revenueCatIdentifier: PricingConfiguration.proAnnual.revenueCatIdentifier
),
]
),

View File

@@ -23,6 +23,9 @@ final nonisolated class Store: ObservableObject, Sendable {
.flatMap(\.package)
.map(\.productIdentifier)
print("fetching products for identifiers: \(identifiers)")
#if DEBUG
try await Task.sleep(for: .seconds(1)) // simulate network delay
#endif
let products = try await Product.products(
for: identifiers.map { .init($0) }
)

View File

@@ -42,7 +42,11 @@ extension ViewModel {
await MainActor.run {
self.updating = false
if shouldDismiss { self.dismiss() }
}
if shouldDismiss {
await MainActor.run {
self.dismiss()
}
}
}
}
@@ -55,7 +59,18 @@ extension ViewModel {
guard !updating else { return }
print(#function, unit, option)
updateAppStoreStatus(initial: false)
Task.detached {
// before we continue, sync any changes from App Store
// this will ask user to sign in if needed
do {
try await store.fetchAppStoreContents()
} catch {
// ignore user's cancellation on restore, not a huge deal
print("updateAppStoreItems error:", error)
}
await MainActor.run { self.updateAppStoreStatus(initial: false) }
}
}
func dismiss() {
@@ -71,18 +86,12 @@ nonisolated extension ViewModel {
await MainActor.run { self.updating = true }
do {
// before we continue, sync any changes from App Store
// this will ask user to sign in if needed
do {
try await store.fetchAppStoreContents()
} catch {
// ignore user's cancellation on restore, not a huge deal
print("updateAppStoreItems error:", error)
}
// now we fetch records from app store
let products = try await store.fetchProducts()
await MainActor.run { self.products = products }
await MainActor.run {
self.products = products
self.updatePackageOptions(with: products)
}
// fetch purchased items if signed in
do {

View File

@@ -24,6 +24,7 @@ class ViewModel: ObservableObject {
@Published var updating = false
@Published var products: [Product] = []
@Published var purchasedItems: Set<String> = []
@Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package)
private(set) weak var associatedController: UIViewController?
@@ -79,6 +80,84 @@ class ViewModel: ObservableObject {
_ = selectePackageOption // ensure selectePackageOption is valid
}
func updatePackageOptions(with products: [Product]) {
var updatedOptions = packageOptions
for (index, option) in updatedOptions.enumerated() {
if let product = products.first(where: { $0.id == option.productIdentifier }) {
let price = product.displayPrice
let description = product.description
let (purchasePrimaryTitle, purchaseSecondaryTitle) = purchaseButtonText(
for: product,
option: option
)
updatedOptions[index] = SKUnitPackageOption(
id: option.id,
price: price,
description: option.description.isEmpty ? description : option.description,
badge: option.badge,
isDefaultSelected: option.isDefaultSelected,
primaryTitle: purchasePrimaryTitle,
secondaryTitle: purchaseSecondaryTitle,
productIdentifier: option.productIdentifier,
revenueCatIdentifier: option.revenueCatIdentifier
)
}
}
packageOptions = updatedOptions
}
private func purchaseButtonText(for product: Product, option: SKUnitPackageOption) -> (String, String) {
let monthlyPrice = calculateMonthlyPrice(for: product, option: option)
if option.productIdentifier.contains(".ai.") {
return ("\(monthlyPrice) per month", "billed annually")
} else {
return ("Upgrade for \(monthlyPrice) per month", "")
}
}
private func calculateMonthlyPrice(for product: Product, option _: SKUnitPackageOption) -> String {
guard let subscription = product.subscription else {
preconditionFailure("Product must have subscription information")
}
switch subscription.subscriptionPeriod.unit {
case .year:
let yearlyPrice = product.price
let monthlyPrice = yearlyPrice / 12.0
// Round up to ensure total price is slightly lower than yearly price
var roundedMonthlyPrice = monthlyPrice
var rounded = Decimal()
NSDecimalRound(&rounded, &roundedMonthlyPrice, 2, .up)
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = product.priceFormatStyle.currencyCode
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
if let formattedMonthlyPrice = formatter.string(from: NSDecimalNumber(decimal: rounded)) {
return formattedMonthlyPrice
}
case .month:
return product.displayPrice
case .week, .day:
preconditionFailure("Unsupported subscription period: \(subscription.subscriptionPeriod.unit)")
@unknown default:
preconditionFailure("Unknown subscription period")
}
return product.displayPrice
}
}
@MainActor
@@ -99,20 +178,24 @@ extension ViewModel {
}
var selectePackageOption: SKUnitPackageOption {
let item = selectedUnit.package
.first { $0.id == selectedPackageIdentifier }
let unitPackageIds = selectedUnit.package.map(\.id)
let item = packageOptions
.first { $0.id == selectedPackageIdentifier && unitPackageIds.contains($0.id) }
if let item { return item }
let defaultItem = selectedUnit.package.first { $0.isDefaultSelected }
let defaultItem = packageOptions
.first { $0.isDefaultSelected && unitPackageIds.contains($0.id) }
if let defaultItem {
selectedPackageIdentifier = defaultItem.id
return defaultItem
}
let lastItem = selectedUnit.package.last!
let lastItem = packageOptions
.first { unitPackageIds.contains($0.id) }!
selectedPackageIdentifier = lastItem.id
return lastItem
}
var availablePackageOptions: [SKUnitPackageOption] {
selectedUnit.package
let unitPackageIds = selectedUnit.package.map(\.id)
return packageOptions.filter { unitPackageIds.contains($0.id) }
}
}

View File

@@ -275,10 +275,10 @@ private extension ChatManager {
let closable = ClosableTask(detachedTask: .detached(operation: {
let eventSource = EventSource()
let dataTask = await eventSource.dataTask(for: request)
let dataTask = eventSource.dataTask(for: request)
var document = ""
self.writeMarkdownContent(document + loadingIndicator, sessionId: sessionId, vmId: vmId)
for await event in await dataTask.events() {
for await event in dataTask.events() {
switch event {
case .open:
print("[*] connection opened")