mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 10:22:55 +08:00
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:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ let package = Package(
|
||||
targets: [
|
||||
.target(
|
||||
name: "AffinePaywall",
|
||||
dependencies: ["AffineResources"],
|
||||
dependencies: ["AffineResources"]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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 you’re free to cancel at any time if it’s not right for you.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AffineColors.textSecondary.color)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.animation(.spring, value: viewModel.updating)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user