fix(ios): complete iap user interface (#13639)

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

- New Features
- In-app purchases fully integrated for Pro and AI plans with restore,
live product loading, and StoreKit test configuration.

- Improvements
- Refreshed paywall: intro animation, delayed close button, smoother
horizontal paging, page dots interaction, per-item reveal animations,
and purchase-state UI (disabled/checked when owned).

- Changes
- "Believer" plan and related screens removed; Pro simplified to Monthly
and Annual offerings.

- Chores
- iOS project and build settings updated for newer toolchain and
StoreKit support.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Lakr
2025-09-25 12:50:12 +08:00
committed by GitHub
parent 3c9d17c983
commit 7a90e1551c
31 changed files with 697 additions and 347 deletions

View File

@@ -16,6 +16,7 @@
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507513692D1924C600AD60C0 /* RootViewController.swift */; };
50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; };
50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */; };
50A485DE2E840A8000F220CE /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50A485DD2E840A8000F220CE /* StoreKit.framework */; };
50FF428A2D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */; };
50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */; };
9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */; };
@@ -57,6 +58,8 @@
50802D5E2D112F7D00694021 /* Intelligents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Intelligents; sourceTree = "<group>"; };
50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
50A285D62D112A5E000D5A6D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
50A485DD2E840A8000F220CE /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
50BBC54E2E840DBC0067C5E2 /* Products.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Products.storekit; sourceTree = "<group>"; };
50CECF1E2E7C1084004487AA /* AffineResources */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineResources; sourceTree = "<group>"; };
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBridgedWindowScript.swift; sourceTree = "<group>"; };
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AffineViewController+AIButton.swift"; sourceTree = "<group>"; };
@@ -96,6 +99,7 @@
buildActionMask = 2147483647;
files = (
5027D47A2E7C5FC100ADD25A /* AffineResources in Frameworks */,
50A485DE2E840A8000F220CE /* StoreKit.framework in Frameworks */,
5027D4782E7C5FBD00ADD25A /* AffinePaywall in Frameworks */,
9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */,
5027D47C2E7C5FC400ADD25A /* AffineGraphQL in Frameworks */,
@@ -110,6 +114,7 @@
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
isa = PBXGroup;
children = (
50A485DD2E840A8000F220CE /* StoreKit.framework */,
C4C97C6B2D03027900BC2AD1 /* libaffine_mobile_native.a */,
9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */,
BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */,
@@ -174,6 +179,7 @@
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */,
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */,
5027D47F2E7C611900ADD25A /* Tools.swift */,
50BBC54E2E840DBC0067C5E2 /* Products.storekit */,
9D90BE1D2CCB9876006677DB /* Assets.xcassets */,
9D90BE1E2CCB9876006677DB /* capacitor.config.json */,
9D90BE1F2CCB9876006677DB /* config.xml */,
@@ -232,7 +238,7 @@
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 0920;
LastUpgradeCheck = 2600;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
@@ -371,6 +377,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -381,6 +388,7 @@
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
@@ -388,8 +396,10 @@
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -422,6 +432,7 @@
ONLY_ACTIVE_ARCH = YES;
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -432,6 +443,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -442,6 +454,7 @@
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
@@ -449,8 +462,10 @@
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -476,6 +491,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
@@ -491,7 +507,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 964G86XT2P;
DEVELOPMENT_TEAM = 73YMMDVT2M;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
@@ -527,7 +543,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 964G86XT2P;
DEVELOPMENT_TEAM = 73YMMDVT2M;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 16.5;

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
@@ -50,6 +50,9 @@
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../App/Products.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

@@ -74,4 +74,29 @@ class AFFiNEViewController: CAPBridgeViewController {
super.viewDidDisappear(animated)
intelligentsButtonTimer?.invalidate()
}
#if DEBUG
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
if motion == .motionShake {
showDebugMenu()
}
}
#endif
}
#if DEBUG
import AffinePaywall
extension AFFiNEViewController {
@objc private func showDebugMenu() {
let alert = UIAlertController(title: "Debug Menu", message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Show Paywall - Pro", style: .default) { _ in
Paywall.presentWall(toController: self, type: "Pro")
})
alert.addAction(UIAlertAction(title: "Show Paywall - AI", style: .default) { _ in
Paywall.presentWall(toController: self, type: "AI")
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
present(alert, animated: true)
}
}
#endif

View File

@@ -0,0 +1,144 @@
{
"appPolicies" : {
"eula" : "",
"policies" : [
{
"locale" : "en_US",
"policyText" : "",
"policyURL" : ""
}
]
},
"identifier" : "B8962ABD",
"nonRenewingSubscriptions" : [
],
"products" : [
],
"settings" : {
"_applicationInternalID" : "6736937980",
"_askToBuyEnabled" : false,
"_billingGracePeriodEnabled" : false,
"_billingIssuesEnabled" : false,
"_developerTeamID" : "73YMMDVT2M",
"_disableDialogs" : false,
"_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 780406221.25107503,
"_locale" : "en_US",
"_renewalBillingIssuesEnabled" : false,
"_storefront" : "USA",
"_storeKitErrors" : [
],
"_timeRate" : 0
},
"subscriptionGroups" : [
{
"id" : "21781265",
"localizations" : [
],
"name" : "AI",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "106.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "6752564901",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "AFFiNE AI Yearly Subscription",
"displayName" : "AFFiNE AI Yearly",
"locale" : "en_US"
}
],
"productID" : "app.affine.pro.ai.Annual",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "AI Yearly Subscription",
"subscriptionGroupID" : "21781265",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
},
{
"id" : "21781263",
"localizations" : [
],
"name" : "Pro",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "7.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "6752564392",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "AFFiNE Pro Monthly Subscription",
"displayName" : "AFFiNE Pro Monthly",
"locale" : "en_US"
}
],
"productID" : "app.affine.pro.Monthly",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Pro Monthly Subscription",
"subscriptionGroupID" : "21781263",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
},
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "81.0",
"familyShareable" : false,
"groupNumber" : 2,
"internalID" : "6752564335",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "AFFiNE Pro Yearly Subscription",
"displayName" : "AFFiNE Pro Yearly",
"locale" : "en_US"
}
],
"productID" : "app.affine.pro.Annual",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "Pro Yearly Subscription",
"subscriptionGroupID" : "21781263",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
}
],
"version" : {
"major" : 4,
"minor" : 0
}
}

View File

@@ -0,0 +1,36 @@
//
// OffsetObservingScrollView.swift
// AffinePaywall
//
// Created by qaq on 9/23/25.
//
import SwiftUI
struct OffsetObservingScrollView<Content: View>: View {
var axes: Axis.Set = [.vertical]
var showsIndicators = true
@Binding var offset: CGPoint
@ViewBuilder var content: () -> Content
private let coordinateSpaceName = UUID()
var body: some View {
ScrollView(axes, showsIndicators: showsIndicators) {
PositionObservingView(
coordinateSpace: .named(coordinateSpaceName),
position: Binding(
get: { offset },
set: { newOffset in
offset = CGPoint(
x: -newOffset.x,
y: -newOffset.y
)
}
),
content: content
)
}
.coordinateSpace(name: coordinateSpaceName)
}
}

View File

@@ -0,0 +1,37 @@
//
// PositionObservingView.swift
// AffinePaywall
//
// Created by qaq on 9/23/25.
//
import SwiftUI
struct PositionObservingView<Content: View>: View {
var coordinateSpace: CoordinateSpace
@Binding var position: CGPoint
@ViewBuilder var content: () -> Content
var body: some View {
content()
.background(GeometryReader { geometry in
Color.clear.preference(
key: PreferenceKey.self,
value: geometry.frame(in: coordinateSpace).origin
)
})
.onPreferenceChange(PreferenceKey.self) { position in
self.position = position
}
}
}
private extension PositionObservingView {
struct PreferenceKey: SwiftUI.PreferenceKey {
static var defaultValue: CGPoint { .zero }
static func reduce(value _: inout CGPoint, nextValue _: () -> CGPoint) {
// No-op
}
}
}

View File

@@ -10,11 +10,14 @@ import SwiftUI
struct SKUnitIntelligentDetailView: View {
@StateObject var viewModel: ViewModel
@State var detailIndexInSwitching: UUID? = nil
@State var detailIndex: Int = 0 {
didSet { lastInteractionDate = Date() }
}
@State var lastInteractionDate: Date = .init()
@State var scrollOffset: CGPoint = .zero
let timer = Timer
.publish(every: 5, on: .main, in: .common)
@@ -28,15 +31,11 @@ struct SKUnitIntelligentDetailView: View {
let height = r.size.height
let width = r.size.width
ScrollViewReader { scrollView in
ScrollView(.horizontal, showsIndicators: false) {
GeometryReader { geometry in
Color.clear
.preference(
key: ViewOffsetKey.self,
value: geometry.frame(in: .named("scrollView")).origin
)
}
.frame(width: 0, height: 0)
OffsetObservingScrollView(
axes: .horizontal,
showsIndicators: false,
offset: $scrollOffset
) {
HStack(spacing: 0) {
ForEach(0 ..< Self.features.count, id: \.self) { featureIndex in
let feature = Self.features[featureIndex]
@@ -47,13 +46,6 @@ struct SKUnitIntelligentDetailView: View {
}
}
}
.coordinateSpace(name: "scrollView")
.onPreferenceChange(ViewOffsetKey.self) { newValue in
let page = Int(round(-newValue.x / width))
guard page != detailIndex else { return }
guard page >= 0, page < Self.features.count else { return }
detailIndex = page
}
.frame(height: height)
.onChange(of: detailIndex) { newValue in
withAnimation(.spring) {
@@ -61,6 +53,15 @@ struct SKUnitIntelligentDetailView: View {
}
}
}
.onChange(of: scrollOffset) { _ in
guard detailIndexInSwitching == nil else { return }
guard width > 0 else { return }
let offset = scrollOffset.x
let newIndex = Int((offset + width / 2) / width)
if newIndex != detailIndex,
(0 ..< Self.features.count).contains(newIndex)
{ detailIndex = newIndex }
}
}
PageDotsView(
@@ -68,6 +69,12 @@ struct SKUnitIntelligentDetailView: View {
total: Self.features.count
) { index in
detailIndex = index
let token = UUID()
detailIndexInSwitching = token
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard detailIndexInSwitching == token else { return }
detailIndexInSwitching = nil
}
}
}
.onReceive(timer) { _ in

View File

@@ -1,36 +0,0 @@
//
// SKUnitBelieverDetailView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import SwiftUI
struct SKUnitBelieverDetailView: View {
@StateObject var viewModel: ViewModel
let features: [Feature] = [
.init("Everything in AFFiNE Pro"),
.init("Life-time Personal usage"),
.init("1TB Cloud Storage"),
]
var body: some View {
VStack(spacing: 24) {
HeadlineView(viewModel: viewModel)
Image("BELIVER_ICON", bundle: .module)
.resizable()
.aspectRatio(contentMode: .fit)
ForEach(features.indices, id: \.self) { index in
let feature = features[index]
ProFeatureRowView(feature: feature, index: index)
}
}
}
}
#Preview {
SKUnitBelieverDetailView(viewModel: .vmPreviewForBeliever)
.padding()
}

View File

@@ -61,7 +61,6 @@ struct CategorySelectionView: View {
return VStack(alignment: .leading, spacing: 12) {
CategorySelectionView(selectedTab: .pro, onSelect: { _ in })
CategorySelectionView(selectedTab: .ai, onSelect: { _ in })
CategorySelectionView(selectedTab: .believer, onSelect: { _ in })
Divider()
PreviewWrapper()
}

View File

@@ -13,7 +13,7 @@ struct HeadlineView: View {
var body: some View {
VStack(spacing: 8) {
Text(viewModel.selectedUnit.primaryText)
.font(.system(size: 24, weight: .heavy))
.font(.system(size: 24, weight: .semibold))
.contentTransition(.numericText())
.animation(.spring.speed(2), value: viewModel.category)
.padding(.top, 8)

View File

@@ -1,5 +1,5 @@
//
// PricingOptionView.swift
// PackageOptionView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
@@ -8,7 +8,7 @@
import AffineResources
import SwiftUI
struct PricingOptionView: View {
struct PackageOptionView: View {
let price: String
let description: String
var badge: String
@@ -102,12 +102,12 @@ struct PricingOptionView: View {
#Preview {
VStack(spacing: 16) {
HStack(spacing: 16) {
PricingOptionView(
PackageOptionView(
price: "$7.99",
description: "Monthly",
isSelected: false
) {}
PricingOptionView(
PackageOptionView(
price: "$6.75",
description: "Annually",
badge: "Save 15%",
@@ -115,13 +115,13 @@ struct PricingOptionView: View {
) {}
}
HStack(spacing: 16) {
PricingOptionView(
PackageOptionView(
price: "$114514",
description: "Monthly",
badge: "Most Popular",
isSelected: true
) {}
PricingOptionView(
PackageOptionView(
price: "$6.75",
description: "Annually",
badge: "Save 15%",

View File

@@ -11,36 +11,67 @@ import SwiftUI
struct PurchaseFooterView: View {
@StateObject var viewModel: ViewModel
var isPurchased: Bool {
let package = viewModel.selectePackageOption
return viewModel.purchasedItems.contains(package.productIdentifier)
}
var body: some View {
VStack(spacing: 16) {
if viewModel.availablePricingOptions.count > 1 {
if viewModel.availablePackageOptions.count > 1 {
HStack(spacing: 8) {
ForEach(viewModel.availablePricingOptions) { option in
PricingOptionView(
ForEach(viewModel.availablePackageOptions) { option in
PackageOptionView(
price: option.price,
description: option.description,
badge: option.badge ?? "",
isSelected: option.id == viewModel.selectedPricingIdentifier
isSelected: option.id == viewModel.selectedPackageIdentifier
) {
viewModel.select(pricingOption: option)
viewModel.select(packageOption: option)
}
}
}
.disabled(isPurchased)
}
TheGiveMeMoneyButtonView(
primaryTitle: viewModel.selectedPricingOption.primaryTitle,
secondaryTitle: viewModel.selectedPricingOption.secondaryTitle,
callback: viewModel.purchase
)
if viewModel.updating {
TheGiveMeMoneyButtonView(
primaryTitle: "Height Placeholder",
secondaryTitle: "",
isPurchased: false
) {}
.hidden()
.background(AffineColors.buttonPrimary.color)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay {
ProgressView()
.progressViewStyle(.circular)
}
.transition(.opacity)
} else {
TheGiveMeMoneyButtonView(
primaryTitle: viewModel.selectePackageOption.primaryTitle,
secondaryTitle: viewModel.selectePackageOption.secondaryTitle,
isPurchased: isPurchased,
callback: viewModel.purchase
)
.transition(.opacity)
}
Button(action: viewModel.restore) {
Text("Restore Purchase")
if isPurchased {
Text("Already Purchased")
} else {
Text("Restore Purchase")
}
}
.font(.system(size: 12))
.buttonStyle(.plain)
.foregroundStyle(AffineColors.textSecondary.color)
.opacity(viewModel.products.isEmpty ? 0 : 1)
.disabled(isPurchased)
}
.animation(.spring, value: viewModel.updating)
}
}

View File

@@ -11,43 +11,55 @@ import SwiftUI
struct TheGiveMeMoneyButtonView: View {
let primaryTitle: String
let secondaryTitle: String
let isPurchased: Bool
let callback: () -> Void
init(
primaryTitle: String = "",
secondaryTitle: String = "",
isPurchased: Bool,
callback: @escaping () -> Void = {}
) {
self.primaryTitle = primaryTitle
self.secondaryTitle = secondaryTitle
self.isPurchased = isPurchased
self.callback = callback
}
var body: some View {
Button { callback() } label: {
HStack(spacing: 4) {
if !primaryTitle.isEmpty {
Text(primaryTitle)
.bold()
.font(.system(size: 16))
.contentTransition(.numericText())
}
if !secondaryTitle.isEmpty {
Text("(\(secondaryTitle))")
.font(.system(size: 12))
.opacity(0.8)
.contentTransition(.numericText())
if isPurchased {
Image(systemName: "checkmark")
.foregroundColor(AffineColors.layerPureWhite.color)
.font(.system(size: 16, weight: .bold))
.padding(12)
} else {
HStack(spacing: 4) {
if !primaryTitle.isEmpty {
Text(primaryTitle)
.bold()
.font(.system(size: 16))
.contentTransition(.numericText())
}
if !secondaryTitle.isEmpty {
Text("(\(secondaryTitle))")
.font(.system(size: 12))
.opacity(0.8)
.contentTransition(.numericText())
}
}
.foregroundColor(AffineColors.layerPureWhite.color)
.padding(12)
}
.foregroundColor(AffineColors.layerPureWhite.color)
.padding(12)
}
.animation(.spring, value: primaryTitle)
.animation(.spring, value: secondaryTitle)
.buttonStyle(.plain)
.frame(maxWidth: .infinity)
.frame(minHeight: 32)
.background(AffineColors.buttonPrimary.color)
.clipShape(RoundedRectangle(cornerRadius: 8))
.disabled(isPurchased)
}
}
@@ -57,19 +69,28 @@ struct TheGiveMeMoneyButtonView: View {
VStack(spacing: 16) {
TheGiveMeMoneyButtonView(
primaryTitle: "Upgrade for $6.75 per month",
secondaryTitle: ""
secondaryTitle: "",
isPurchased: false
)
TheGiveMeMoneyButtonView(
primaryTitle: "Upgrade for $10 per month",
secondaryTitle: ""
secondaryTitle: "",
isPurchased: false
)
TheGiveMeMoneyButtonView(
primaryTitle: "$8.9 per month",
secondaryTitle: "billed annually"
secondaryTitle: "billed annually",
isPurchased: false
)
TheGiveMeMoneyButtonView(
primaryTitle: "Upgrade for $499",
secondaryTitle: ""
secondaryTitle: "",
isPurchased: false
)
TheGiveMeMoneyButtonView(
primaryTitle: "Upgrade for $499",
secondaryTitle: "",
isPurchased: true
)
}
.padding(32)

View File

@@ -1,65 +0,0 @@
//
// ProFeaturesCardView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct ProFeaturesCardView: View {
let features: [Feature]
let headerText: String
let timer = Timer
.publish(every: 0.08, on: .main, in: .common)
.autoconnect()
@State var animationIndex: Int64 = 0
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if !headerText.isEmpty {
Text(headerText)
.font(.system(size: 13))
.foregroundColor(AffineColors.textSecondary.color)
.padding(.horizontal, 4)
}
ForEach(Array(features.enumerated()), id: \.element.id) { index, feature in
ProFeatureRowView(feature: feature, index: index)
.opacity(index < animationIndex ? 1 : 0)
}
}
.animation(.spring.speed(2), value: animationIndex)
.onChange(of: features) { _ in animationIndex = 0 }
.onReceive(timer) { _ in animationIndex += 1 }
.clipped()
.padding(16)
.background(AffineColors.layerBackgroundPrimary.color)
.cornerRadius(16)
.shadow(color: AffineColors.layerBorder.color.opacity(0.08), radius: 8, y: 2)
.animation(.spring.speed(2), value: features)
}
}
#Preview("Pro") {
ProFeaturesCardView(features: SKUnitSubcategoryProPlan.default.features, headerText: SKUnitSubcategoryProPlan.default.headerText)
.padding()
.background(Color.gray.ignoresSafeArea())
}
#Preview("Pro team") {
ProFeaturesCardView(
features: SKUnitSubcategoryProPlan.team.features,
headerText: SKUnitSubcategoryProPlan.team.headerText
)
.padding()
.background(Color.gray.ignoresSafeArea())
}
#Preview("Self Hosted") {
ProFeaturesCardView(features: SKUnitSubcategoryProPlan.selfHost.features, headerText: SKUnitSubcategoryProPlan.selfHost.headerText)
.padding()
.background(Color.gray.ignoresSafeArea())
}

View File

@@ -11,35 +11,82 @@ import SwiftUI
struct SKUnitProDetailView: View {
@StateObject var viewModel: ViewModel
@State var selection: SKUnitSubcategoryProPlan = .default
@State var selection: SKUnitSubcategoryProPlan
@State var headerText: String
@State var features: [Feature]
@State var animationIndex: Int64 = 0
let timer = Timer
.publish(every: 0.075, on: .main, in: .common)
.autoconnect()
init(viewModel: ViewModel) {
_viewModel = .init(wrappedValue: viewModel)
let item = SKUnitSubcategoryProPlan.default
_selection = .init(initialValue: item)
_headerText = .init(initialValue: item.headerText)
_features = .init(initialValue: item.features)
}
var body: some View {
VStack(spacing: 24) {
Picker("Plan", selection: $selection) {
ForEach(SKUnitSubcategoryProPlan.allCases) { plan in
Text(plan.title).tag(plan)
if SKUnitSubcategoryProPlan.allCases.count > 1 {
Picker("Plan", selection: $selection) {
ForEach(SKUnitSubcategoryProPlan.allCases) { plan in
Text(plan.title).tag(plan)
}
}
.pickerStyle(.segmented)
.onChange(of: selection) { _ in
viewModel.select(subcategory: selection)
}
}
.pickerStyle(.segmented)
.onChange(of: selection) { _ in
viewModel.select(subcategory: selection)
}
HeadlineView(viewModel: viewModel)
ScrollView {
ProFeaturesCardView(
features: selection.features,
headerText: selection.headerText
)
VStack(alignment: .leading, spacing: 16) {
if !headerText.isEmpty {
Text(headerText)
.font(.system(size: 13))
.foregroundColor(AffineColors.textSecondary.color)
.contentTransition(.numericText())
.transition(.opacity)
.padding(.horizontal, 4)
}
ForEach(Array(features.enumerated()), id: \.element.id) { index, feature in
ProFeatureRowView(feature: feature, index: index)
.opacity(index < animationIndex ? 1 : 0)
}
}
.clipped()
.padding(16)
.background(AffineColors.layerBackgroundPrimary.color)
.cornerRadius(16)
.shadow(color: AffineColors.layerBorder.color.opacity(0.08), radius: 8, y: 2)
.padding(16)
}
.padding(-16)
.animation(.spring.speed(2), value: animationIndex)
.onReceive(timer) { _ in animationIndex += 1 }
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .top
)
.onChange(of: selection) { _ in updateSelectionContents() }
}
}
func updateSelectionContents() {
let headerText = selection.headerText
if self.headerText != headerText {
self.headerText = headerText
}
let features = selection.features
if self.features != features {
self.features = features
}
}
}

View File

@@ -7,7 +7,7 @@
import Foundation
struct Feature: Identifiable, Equatable {
struct Feature: Identifiable, Equatable, Hashable {
var id = UUID()
var text: String
var isHighlighted: Bool // For text like "Everything in AFFINE Pro"

View File

@@ -13,13 +13,15 @@ extension SKUnit {
category: SKUnitCategory.ai,
primaryText: "AFFINE AI",
secondaryText: "A true multimodal AI copilot.",
pricing: [
SKUnitPricingOption(
package: [
SKUnitPackageOption(
price: "$8.9 per month",
description: "",
isDefaultSelected: true,
primaryTitle: "$8.9 per month",
secondaryTitle: "billed annually"
secondaryTitle: "billed annually",
productIdentifier: "app.affine.pro.ai.Annual",
revenueCatIdentifier: "app.affine.pro.ai.Annual"
),
]
),

View File

@@ -1,27 +0,0 @@
//
// SKUnit+Believer.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
extension SKUnit {
static let believerUnits: [SKUnit] = [
SKUnit(
category: SKUnitCategory.believer,
primaryText: "Believer Plan",
secondaryText: "AFFINE's Everything",
pricing: [
SKUnitPricingOption(
price: "$499",
description: "",
isDefaultSelected: true,
primaryTitle: "Upgrade for $499",
secondaryTitle: ""
),
]
),
]
}

View File

@@ -14,67 +14,25 @@ extension SKUnit {
subcategory: SKUnitSubcategoryProPlan.default,
primaryText: "Pro",
secondaryText: "For family and small teams.",
pricing: [
SKUnitPricingOption(
package: [
SKUnitPackageOption(
price: "$7.99",
description: "Monthly",
isDefaultSelected: false,
primaryTitle: "Upgrade for $7.99/month",
secondaryTitle: ""
secondaryTitle: "",
productIdentifier: "app.affine.pro.Monthly",
revenueCatIdentifier: "app.affine.pro.Monthly"
),
SKUnitPricingOption(
SKUnitPackageOption(
price: "$6.75",
description: "Annual",
badge: "Save 15%",
isDefaultSelected: true,
primaryTitle: "Upgrade for $6.75/month",
secondaryTitle: ""
),
]
),
SKUnit(
category: SKUnitCategory.pro,
subcategory: SKUnitSubcategoryProPlan.team,
primaryText: "Pro team",
secondaryText: "Best for scalable teams.",
pricing: [
SKUnitPricingOption(
price: "$12",
description: "Per seat monthly",
isDefaultSelected: false,
primaryTitle: "Upgrade for $12/month",
secondaryTitle: ""
),
SKUnitPricingOption(
price: "$10",
description: "Annual",
badge: "Save 15%",
isDefaultSelected: true,
primaryTitle: "Upgrade for $10/month",
secondaryTitle: ""
),
]
),
SKUnit(
category: SKUnitCategory.pro,
subcategory: SKUnitSubcategoryProPlan.selfHost,
primaryText: "Self Hosted team",
secondaryText: "Best for scalable teams.",
pricing: [
SKUnitPricingOption(
price: "$12",
description: "Per seat monthly",
isDefaultSelected: false,
primaryTitle: "Upgrade for $12/month",
secondaryTitle: ""
),
SKUnitPricingOption(
price: "$10",
description: "Annual",
badge: "Save 15%",
isDefaultSelected: true,
primaryTitle: "Upgrade for $10/month",
secondaryTitle: ""
secondaryTitle: "",
productIdentifier: "app.affine.pro.Annual",
revenueCatIdentifier: "app.affine.pro.Annual"
),
]
),

View File

@@ -13,20 +13,20 @@ struct SKUnit: Identifiable, Sendable {
let subcategory: any SKUnitSubcategorizable
let primaryText: String
let secondaryText: String
let pricing: [SKUnitPricingOption]
let package: [SKUnitPackageOption]
init(
category: SKUnitCategory,
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single,
primaryText: String,
secondaryText: String,
pricing: [SKUnitPricingOption]
package: [SKUnitPackageOption]
) {
self.category = category
self.subcategory = subcategory
self.primaryText = primaryText
self.secondaryText = secondaryText
self.pricing = pricing
self.package = package
}
}
@@ -34,7 +34,6 @@ extension SKUnit {
static let allUnits: [SKUnit] = [
proUnits,
aiUnits,
believerUnits,
].flatMap(\.self)
static func units(for category: SKUnitCategory) -> [SKUnit] {

View File

@@ -12,7 +12,6 @@ enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
case pro
case ai
case believer
}
extension SKUnitCategory {
@@ -20,7 +19,6 @@ extension SKUnitCategory {
switch self {
case .pro: "AFFINE.Pro"
case .ai: "AI"
case .believer: "Believer"
}
}
}

View File

@@ -1,5 +1,5 @@
//
// SKUnitPricingOption.swift
// SKUnitPackageOption.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
@@ -7,10 +7,10 @@
import Foundation
struct SKUnitPricingOption: Identifiable, Equatable {
struct SKUnitPackageOption: Identifiable, Equatable {
var id: UUID
// pricing selection button
// package selection button
var price: String
var description: String
var badge: String?
@@ -20,6 +20,10 @@ struct SKUnitPricingOption: Identifiable, Equatable {
var primaryTitle: String
var secondaryTitle: String
// product identifiers
var productIdentifier: String
var revenueCatIdentifier: String
init(
id: UUID = UUID(),
price: String,
@@ -27,7 +31,9 @@ struct SKUnitPricingOption: Identifiable, Equatable {
badge: String? = nil,
isDefaultSelected: Bool = false,
primaryTitle: String,
secondaryTitle: String
secondaryTitle: String,
productIdentifier: String,
revenueCatIdentifier: String
) {
self.id = id
self.price = price
@@ -36,5 +42,7 @@ struct SKUnitPricingOption: Identifiable, Equatable {
self.isDefaultSelected = isDefaultSelected
self.primaryTitle = primaryTitle
self.secondaryTitle = secondaryTitle
self.productIdentifier = productIdentifier
self.revenueCatIdentifier = revenueCatIdentifier
}
}

View File

@@ -9,14 +9,10 @@ import Foundation
enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable {
case `default`
case team
case selfHost
var title: String {
switch self {
case .default: "Pro"
case .team: "Pro team"
case .selfHost: "Self Hosted"
}
}
@@ -24,10 +20,6 @@ enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable {
switch self {
case .default:
"For family and small teams."
case .team:
"Best for scalable teams."
case .selfHost:
"Best for scalable teams."
}
}
}
@@ -37,10 +29,6 @@ extension SKUnitSubcategoryProPlan {
switch self {
case .default:
"Include in Pro"
case .team:
"Include in Team Workspace"
case .selfHost:
"Both in Teams & Enterprise"
}
}
@@ -56,24 +44,6 @@ extension SKUnitSubcategoryProPlan {
Feature("Community Support"),
Feature("Real-time Syncing & Collaboration for more people"),
]
case .team:
[
Feature("Everything in AFFINE Pro", isHighlighted: true),
Feature("100 GB initial storage + 20 GB per seat"),
Feature("500 MB of maximum file size"),
Feature("Unlimited team members (10+ seats)"),
Feature("Multiple admin roles"),
Feature("Priority customer support"),
]
case .selfHost:
[
Feature("Everything in Self Hosted FOSS"),
Feature("100 GB initial storage + 20 GB per seat"),
Feature("500 MB of maximum file size"),
Feature("Unlimited team members (10+ seats)"),
Feature("Multiple admin roles"),
Feature("Priority customer support"),
]
}
}
}

View File

@@ -0,0 +1,59 @@
//
// Store.swift
// AffinePaywall
//
// Created by qaq on 9/24/25.
//
import StoreKit
let store = Store.shared
final nonisolated class Store: ObservableObject, Sendable {
static let shared = Store()
private init() {}
func fetchAppStoreContents() async throws {
try await AppStore.sync()
}
func fetchProducts() async throws -> [Product] {
let identifiers = SKUnit.allUnits
.flatMap(\.package)
.map(\.productIdentifier)
print("fetching products for identifiers: \(identifiers)")
let products = try await Product.products(
for: identifiers.map { .init($0) }
)
if products.count != identifiers.count {
throw NSError(domain: "AffinePaywall", code: -1, userInfo: [
NSLocalizedDescriptionKey: String(localized: "Failed to fetch all products from App Store."),
])
}
return products
}
func fetchEntitlements() async throws -> Set<String> {
var purchasedItems: Set<String> = []
for await result in Transaction.currentEntitlements {
if case let .verified(transaction) = result {
guard transaction.revocationDate == nil else { continue }
switch transaction.productType {
case .nonConsumable, .consumable:
purchasedItems.insert(transaction.productID)
case .autoRenewable, .nonRenewable:
if let status = await transaction.subscriptionStatus,
status.state == .subscribed
{ purchasedItems.insert(transaction.productID) }
default:
break
}
}
}
return purchasedItems
}
}

View File

@@ -6,26 +6,122 @@
//
import Foundation
import UIKit
extension ViewModel {
func purchase() {
let unit = selectedUnit
let option = selectedPricingOption
let option = selectePackageOption
assert(!updating)
guard !updating else { return }
print(#function, unit, option)
Task.detached {
await MainActor.run { self.updating = true }
var shouldDismiss = false
let product = await self.products.first {
$0.id == option.productIdentifier
}
if let product {
let result = try await product.purchase()
switch result {
case .pending:
break
case let .success(transaction):
print("purchase success", transaction)
shouldDismiss = true
case .userCancelled:
break
@unknown default:
assertionFailure()
}
} else { assertionFailure() } // should never happen
await MainActor.run {
self.updating = false
if shouldDismiss { self.dismiss() }
}
}
}
func restore() {
let unit = selectedUnit
let option = selectedPricingOption
let option = selectePackageOption
assert(!updating)
guard !updating else { return }
print(#function, unit, option)
updateAppStoreStatus(initial: false)
}
func dismiss() {
let unit = selectedUnit
let option = selectedPricingOption
print(#function, unit, option)
print(#function)
associatedController?.dismiss(animated: true)
}
}
nonisolated extension ViewModel {
func updateAppStoreStatusExecute(initial: Bool) async {
guard await !updating else { return }
guard let controller = await associatedController else { return }
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 }
// fetch purchased items if signed in
do {
let purchase = try await store.fetchEntitlements()
await MainActor.run { self.purchasedItems = purchase }
} catch {
print("fetchEntitlements error:", error)
if !initial { throw error }
}
// select the package under purchased items if any
let availablePackages = await availablePackageOptions
let purchase = await purchasedItems
let purchasedPackages = availablePackages.filter {
purchase.contains($0.productIdentifier)
}
assert(purchasedPackages.count <= 1)
if let firstPurchased = purchasedPackages.first {
await MainActor.run {
self.select(packageOption: firstPurchased)
}
}
} catch {
await MainActor.run {
let alert = UIAlertController(
title: String(localized: "Error"),
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(
UIAlertAction(
title: String(localized: "OK"),
style: .default
) { [self] _ in dismiss() }
)
controller.present(alert, animated: true)
}
}
await MainActor.run { self.updating = false }
}
}

View File

@@ -20,10 +20,4 @@ extension ViewModel {
vm.select(category: .ai)
return vm
}()
static let vmPreviewForBeliever: ViewModel = {
let vm = ViewModel()
vm.select(category: .believer)
return vm
}()
}

View File

@@ -5,6 +5,7 @@
// Created by qaq on 9/18/25.
//
import StoreKit
import SwiftUI
@MainActor
@@ -15,12 +16,30 @@ class ViewModel: ObservableObject {
@Published private(set) var category: SKUnitCategory = .pro
@Published private(set) var subcategory: any SKUnitSubcategorizable = SKUnitSubcategoryProPlan.default
@Published private(set) var selectedPricingIdentifier: UUID = SKUnit.unit(
@Published private(set) var selectedPackageIdentifier: UUID = SKUnit.unit(
for: .pro,
subcategory: SKUnitSubcategoryProPlan.default
)!.pricing.first { $0.isDefaultSelected }!.id
)!.package.first { $0.isDefaultSelected }!.id
init() {}
@Published var updating = false
@Published var products: [Product] = []
@Published var purchasedItems: Set<String> = []
private(set) weak var associatedController: UIViewController?
init() {
updateAppStoreStatus(initial: true)
}
func updateAppStoreStatus(initial: Bool) {
Task.detached {
await self.updateAppStoreStatusExecute(initial: initial)
}
}
func bind(controller: UIViewController) {
associatedController = controller
}
func select(category: SKUnitCategory) {
self.category = category
@@ -30,7 +49,7 @@ class ViewModel: ObservableObject {
if !subcategoryExists {
subcategory = units.first!.subcategory
}
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
_ = selectePackageOption // ensure selectePackageOption is valid
}
func select(subcategory: any SKUnitSubcategorizable) {
@@ -45,20 +64,20 @@ class ViewModel: ObservableObject {
} else {
self.subcategory = subcategory
}
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
_ = selectePackageOption // ensure selectePackageOption is valid
}
func select(pricingOption option: SKUnitPricingOption) {
selectedPricingIdentifier = option.id
func select(packageOption option: SKUnitPackageOption) {
selectedPackageIdentifier = option.id
let unit = availableUnits
.first { unit in
unit.pricing.contains { $0.id == option.id }
unit.package.contains { $0.id == option.id }
}!
category = unit.category
subcategory = unit.subcategory
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
_ = selectePackageOption // ensure selectePackageOption is valid
}
}
@@ -79,21 +98,21 @@ extension ViewModel {
return item
}
var selectedPricingOption: SKUnitPricingOption {
let item = selectedUnit.pricing
.first { $0.id == selectedPricingIdentifier }
var selectePackageOption: SKUnitPackageOption {
let item = selectedUnit.package
.first { $0.id == selectedPackageIdentifier }
if let item { return item }
let defaultItem = selectedUnit.pricing.first { $0.isDefaultSelected }
let defaultItem = selectedUnit.package.first { $0.isDefaultSelected }
if let defaultItem {
selectedPricingIdentifier = defaultItem.id
selectedPackageIdentifier = defaultItem.id
return defaultItem
}
let lastItem = selectedUnit.pricing.last!
selectedPricingIdentifier = lastItem.id
let lastItem = selectedUnit.package.last!
selectedPackageIdentifier = lastItem.id
return lastItem
}
var availablePricingOptions: [SKUnitPricingOption] {
selectedUnit.pricing
var availablePackageOptions: [SKUnitPackageOption] {
selectedUnit.package
}
}

View File

@@ -13,6 +13,9 @@ struct AffinePaywallPageView: View {
@Environment(\.dismiss) var dismiss
@State private var presentAnimation = false
@State private var showCloseButton = false
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
@@ -24,10 +27,17 @@ struct AffinePaywallPageView: View {
Button {
viewModel.dismiss()
} label: {
Image(AffineIcons.close.rawValue)
AffineIcons.close.image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
}
.buttonStyle(.plain)
.foregroundColor(AffineColors.textSecondary.color)
.opacity(showCloseButton ? 1 : 0)
.disabled(!showCloseButton)
.animation(.spring, value: showCloseButton)
}
ZStack(alignment: .topLeading) {
Spacer()
@@ -45,11 +55,22 @@ struct AffinePaywallPageView: View {
.animation(.spring.speed(2), value: viewModel.category)
PurchaseFooterView(viewModel: viewModel)
.animation(.spring.speed(2), value: viewModel.selectedPricingIdentifier)
.animation(.spring.speed(2), value: viewModel.selectedPackageIdentifier)
}
.padding()
.opacity(presentAnimation ? 1 : 0)
.scaleEffect(presentAnimation ? 1 : 0.95, anchor: .top)
.animation(.spring, value: presentAnimation)
.onAppear {
presentAnimation = true
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
showCloseButton = true
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
AffineColors.layerBackgroundSecondary.color
.ignoresSafeArea()
)
}
@@ -60,8 +81,6 @@ struct AffinePaywallPageView: View {
SKUnitProDetailView(viewModel: viewModel)
case .ai:
SKUnitIntelligentDetailView(viewModel: viewModel)
case .believer:
SKUnitBelieverDetailView(viewModel: viewModel)
}
}
}

View File

@@ -1,12 +1,12 @@
//
// File.swift
// Paywall.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import UIKit
import SwiftUI
import UIKit
public enum Paywall {
@MainActor
@@ -15,13 +15,19 @@ public enum Paywall {
type: String
) {
let viewModel = ViewModel()
switch type {
// TODO: FIGURE OUT PAYWALL TYPES
switch type.lowercased() {
case "pro":
viewModel.select(category: .pro)
viewModel.select(subcategory: SKUnitSubcategoryProPlan.default)
case "ai":
viewModel.select(category: .ai)
viewModel.select(subcategory: SKUnitSingleSubcategory.single)
default:
break
}
let view = AffinePaywallPageView(viewModel: viewModel)
let hostingController = UIHostingController(rootView: view)
viewModel.bind(controller: hostingController)
hostingController.modalPresentationStyle = .overFullScreen
hostingController.modalTransitionStyle = .coverVertical
hostingController.preferredContentSize = CGSize(width: 555, height: 555) // for iPads

View File

@@ -1,16 +0,0 @@
//
// ViewOffsetKey.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import SwiftUI
@MainActor
struct ViewOffsetKey: @MainActor PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
value = nextValue()
}
}