feat(ios): [IAP] Paywall Initial Commit (#13609)

Requires https://github.com/toeverything/AFFiNE/pull/13606 to be merged.

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

- New Features
- Introduced an in-app Paywall with Pro, AI, and Believer plans, feature
previews, paging dots, and selectable pricing options.
- Added purchase and restore actions, plus a unified, polished UI using
new color/icon resources.

- Documentation
  - Added Swift Code Style Guidelines.

- Chores
- Updated dependencies (including MarkdownView 3.4.2), added new
resource packages, and removed an unused dependency.
  - Raised iOS deployment target to 16.5 and refreshed project settings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
This commit is contained in:
Lakr
2025-09-19 19:01:46 +08:00
committed by GitHub
parent 1f228382c2
commit 360c9545f4
53 changed files with 1718 additions and 8 deletions

View File

@@ -8,6 +8,10 @@
/* Begin PBXBuildFile section */
2E0DD47B57B994A104B25EED /* Pods_AFFiNE.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */; };
5027D4782E7C5FBD00ADD25A /* AffinePaywall in Frameworks */ = {isa = PBXBuildFile; productRef = 5027D4772E7C5FBD00ADD25A /* AffinePaywall */; };
5027D47A2E7C5FC100ADD25A /* AffineResources in Frameworks */ = {isa = PBXBuildFile; productRef = 5027D4792E7C5FC100ADD25A /* AffineResources */; };
5027D47C2E7C5FC400ADD25A /* AffineGraphQL in Frameworks */ = {isa = PBXBuildFile; productRef = 5027D47B2E7C5FC400ADD25A /* AffineGraphQL */; };
5027D4802E7C611900ADD25A /* Tools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5027D47F2E7C611900ADD25A /* Tools.swift */; };
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507513692D1924C600AD60C0 /* RootViewController.swift */; };
50802D612D112F8700694021 /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50802D602D112F8700694021 /* Intelligents */; settings = {ATTRIBUTES = (Required, ); }; };
50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; };
@@ -47,6 +51,8 @@
/* Begin PBXFileReference section */
3256F4410D881A03FE77D092 /* Pods-AFFiNE.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AFFiNE.debug.xcconfig"; path = "Pods/Target Support Files/Pods-AFFiNE/Pods-AFFiNE.debug.xcconfig"; sourceTree = "<group>"; };
5027D4762E7C5FB700ADD25A /* AffinePaywall */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffinePaywall; sourceTree = "<group>"; };
5027D47F2E7C611900ADD25A /* Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tools.swift; sourceTree = "<group>"; };
5039CC962D1D42C700874F32 /* AffineGraphQL */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineGraphQL; sourceTree = "<group>"; };
504EC3041FED79650016851F /* AFFiNE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AFFiNE.app; sourceTree = BUILT_PRODUCTS_DIR; };
507513692D1924C600AD60C0 /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = "<group>"; };
@@ -91,7 +97,10 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5027D47A2E7C5FC100ADD25A /* AffineResources in Frameworks */,
5027D4782E7C5FBD00ADD25A /* AffinePaywall in Frameworks */,
9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */,
5027D47C2E7C5FC400ADD25A /* AffineGraphQL in Frameworks */,
50802D612D112F8700694021 /* Intelligents in Frameworks */,
2E0DD47B57B994A104B25EED /* Pods_AFFiNE.framework in Frameworks */,
);
@@ -136,6 +145,7 @@
isa = PBXGroup;
children = (
5039CC962D1D42C700874F32 /* AffineGraphQL */,
5027D4762E7C5FB700ADD25A /* AffinePaywall */,
50CECF1E2E7C1084004487AA /* AffineResources */,
50802D5E2D112F7D00694021 /* Intelligents */,
);
@@ -165,6 +175,7 @@
9D90BE1B2CCB9876006677DB /* AffineViewController.swift */,
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */,
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */,
5027D47F2E7C611900ADD25A /* Tools.swift */,
9D90BE1D2CCB9876006677DB /* Assets.xcassets */,
9D90BE1E2CCB9876006677DB /* capacitor.config.json */,
9D90BE1F2CCB9876006677DB /* config.xml */,
@@ -340,6 +351,7 @@
50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */,
9D90BE272CCB9876006677DB /* AffineViewController.swift in Sources */,
9D90BE282CCB9876006677DB /* AppDelegate.swift in Sources */,
5027D4802E7C611900ADD25A /* Tools.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -484,7 +496,7 @@
DEVELOPMENT_TEAM = 964G86XT2P;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -520,7 +532,7 @@
DEVELOPMENT_TEAM = 964G86XT2P;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -569,6 +581,18 @@
/* End XCConfigurationList section */
/* Begin XCSwiftPackageProductDependency section */
5027D4772E7C5FBD00ADD25A /* AffinePaywall */ = {
isa = XCSwiftPackageProductDependency;
productName = AffinePaywall;
};
5027D4792E7C5FC100ADD25A /* AffineResources */ = {
isa = XCSwiftPackageProductDependency;
productName = AffineResources;
};
5027D47B2E7C5FC400ADD25A /* AffineGraphQL */ = {
isa = XCSwiftPackageProductDependency;
productName = AffineGraphQL;
};
50802D602D112F8700694021 /* Intelligents */ = {
isa = XCSwiftPackageProductDependency;
productName = Intelligents;

View File

@@ -32,8 +32,8 @@ class AFFiNEViewController: CAPBridgeViewController {
CookiePlugin(),
HashcashPlugin(),
NavigationGesturePlugin(),
// IntelligentsPlugin(representController: self), // no longer put in use
NbStorePlugin(),
PayWallPlugin(associatedController: self),
]
plugins.forEach { bridge?.registerPluginInstance($0) }
}

View File

@@ -1,8 +1,18 @@
import AffinePaywall
import Capacitor
import Foundation
import SwiftUI
import UIKit
@objc(PayWallPlugin)
public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
init(associatedController: UIViewController? = nil) {
controller = associatedController
super.init()
}
weak var controller: UIViewController?
public let identifier = "PayWallPlugin"
public let jsName = "PayWall"
public let pluginMethods: [CAPPluginMethod] = [
@@ -12,14 +22,17 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
@objc func showPayWall(_ call: CAPPluginCall) {
do {
let type = try call.getStringEnsure("type")
let controller = try controller.get()
// TODO: Implement actual paywall logic here
// For now, just log the type and resolve
print("PayWall: Showing paywall of type: \(type)")
// TODO: GET TO KNOW THE PAYWALL TYPE
print("[*] showing paywall of type: \(type)")
DispatchQueue.main.async {
Paywall.presentWall(toController: controller, type: type)
}
call.resolve(["success": true, "type": type])
} catch {
call.reject("Failed to show paywall", nil, error)
call.reject("failed to show paywall", nil, error)
}
}
}
}

View File

@@ -0,0 +1,21 @@
//
// Tools.swift
// AFFiNE
//
// Created by qaq on 9/18/25.
//
import Foundation
extension Optional {
func get(_ failure: String? = nil) throws -> Wrapped {
guard let self else {
if let failure {
throw NSError(domain: #function, code: -1, userInfo: [NSLocalizedDescriptionKey: failure])
} else {
throw NSError(domain: #function, code: -1)
}
}
return self
}
}

View File

@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@@ -0,0 +1,27 @@
// swift-tools-version: 6.2
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "AffinePaywall",
platforms: [
.iOS(.v16),
.macOS(.v14), // just for build so LLM can verify their code
],
products: [
.library(
name: "AffinePaywall",
targets: ["AffinePaywall"]
),
],
dependencies: [
.package(path: "../AffineResources"),
],
targets: [
.target(
name: "AffinePaywall",
dependencies: ["AffineResources"],
),
]
)

View File

@@ -0,0 +1,64 @@
//
// IntelligentFeatureView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct IntelligentFeatureView: View {
let feature: Feature
struct Feature: Identifiable {
let id: UUID = .init()
let preview: String
let icon: String
let title: String
let features: [String]
}
var body: some View {
VStack(spacing: 24) {
Image(feature.preview, bundle: .module)
.resizable()
.aspectRatio(contentMode: .fit)
HStack(spacing: 8) {
Image(feature.icon, bundle: .module)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
Text(feature.title)
.font(.system(size: 24, weight: .semibold, design: .default))
}
VStack(alignment: .leading, spacing: 12) {
ForEach(feature.features, id: \.self) { item in
HStack(alignment: .firstTextBaseline, spacing: 12) {
Rectangle()
.frame(width: 4, height: 10)
.foregroundStyle(.clear)
.overlay {
Image(systemName: "circle.fill")
.font(.system(size: 4))
.foregroundColor(AffineColors.textSecondary.color)
}
Text(item)
.font(.system(size: 16))
.foregroundColor(AffineColors.textSecondary.color)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
#Preview {
IntelligentFeatureView(
feature: SKUnitIntelligentDetailView.features.first!
)
.padding()
}

View File

@@ -0,0 +1,44 @@
//
// SKUnitIntelligentDetailView+Feature.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
extension SKUnitIntelligentDetailView {
static let features: [IntelligentFeatureView.Feature] = [
.init(
preview: "AI_PREVIEW_WRITE",
icon: "AI_TEXT",
title: "Write with you",
features: [
"Create quality content from sentences to articles on topics you need",
"Rewrite like the professionals",
"Change the tones / fix spelling & grammar",
]
),
.init(
preview: "AI_PREVIEW_DRAW",
icon: "AI_PEN",
title: "Draw with you",
features: [
"Visualize your mind, magically",
"Turn your outline into beautiful, engaging presentations(Beta)",
"Summarize your content into structured mind-maps",
]
),
.init(
preview: "AI_PREVIEW_PLAN",
icon: "AI_CHECK",
title: "Plan with you",
features: [
"Memorize and tidy up your knowledge",
"Auto-sorting and auto-tagging (Coming soon)",
"Privacy ensured",
]
),
]
}

View File

@@ -0,0 +1,84 @@
//
// SKUnitIntelligentDetailView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct SKUnitIntelligentDetailView: View {
@StateObject var viewModel: ViewModel
@State var detailIndex: Int = 0 {
didSet { lastInteractionDate = Date() }
}
@State var lastInteractionDate: Date = .init()
let timer = Timer
.publish(every: 5, on: .main, in: .common)
.autoconnect()
var body: some View {
VStack(spacing: 24) {
HeadlineView(viewModel: viewModel)
GeometryReader { r in
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)
HStack(spacing: 0) {
ForEach(0 ..< Self.features.count, id: \.self) { featureIndex in
let feature = Self.features[featureIndex]
IntelligentFeatureView(feature: feature)
.padding()
.frame(width: width, height: height)
.id(featureIndex)
}
}
}
.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) {
scrollView.scrollTo(newValue)
}
}
}
}
PageDotsView(
current: detailIndex,
total: Self.features.count
) { index in
detailIndex = index
}
}
.onReceive(timer) { _ in
if Date().timeIntervalSince(lastInteractionDate) > 5 {
detailIndex = (detailIndex + 1) % Self.features.count
}
}
}
}
#Preview {
SKUnitIntelligentDetailView(viewModel: .vmPreviewForAI)
.padding()
}

View File

@@ -0,0 +1,36 @@
//
// 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

@@ -0,0 +1,70 @@
//
// CategorySelectionView.swift
// AffinePaywall
//
// Created by qaq on 9/17/25.
//
import AffineResources
import SwiftUI
struct CategorySelectionView: View {
let selectedTab: SKUnitCategory
let onSelect: (SKUnitCategory) -> Void
var body: some View {
HStack(spacing: 16) {
ForEach(SKUnitCategory.allCases) { tab in
TabItem(type: tab, isSelected: tab == selectedTab)
.onTapGesture { onSelect(tab) }
}
}
.animation(.spring.speed(2), value: selectedTab)
}
struct TabItem: View {
let type: SKUnitCategory
let isSelected: Bool
var font: Font {
if isSelected {
.system(size: 24, weight: .bold)
} else {
.system(size: 24, weight: .regular)
}
}
var color: Color {
if isSelected {
AffineColors.textPrimary.color
} else {
AffineColors.textSecondary.color
}
}
var body: some View {
Text(type.title)
.lineLimit(1)
.font(font)
.foregroundStyle(color)
}
}
}
#Preview {
struct PreviewWrapper: View {
@State var selectedTab: SKUnitCategory = .pro
var body: some View {
CategorySelectionView(selectedTab: selectedTab, onSelect: { selectedTab = $0 })
}
}
return VStack(alignment: .leading, spacing: 12) {
CategorySelectionView(selectedTab: .pro, onSelect: { _ in })
CategorySelectionView(selectedTab: .ai, onSelect: { _ in })
CategorySelectionView(selectedTab: .believer, onSelect: { _ in })
Divider()
PreviewWrapper()
}
.padding()
.background(Color.gray.opacity(0.25).ignoresSafeArea())
}

View File

@@ -0,0 +1,28 @@
//
// HeadlineView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct HeadlineView: View {
@StateObject var viewModel: ViewModel
var body: some View {
VStack(spacing: 8) {
Text(viewModel.selectedUnit.primaryText)
.font(.system(size: 24, weight: .heavy))
.contentTransition(.numericText())
.animation(.spring.speed(2), value: viewModel.category)
.padding(.top, 8)
Text(viewModel.selectedUnit.secondaryText)
.font(.system(size: 16))
.foregroundStyle(AffineColors.textSecondary.color)
.contentTransition(.numericText())
.animation(.spring.speed(2), value: viewModel.category)
}
}
}

View File

@@ -0,0 +1,50 @@
//
// PageDotsView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct PageDotsView: View {
let current: Int
let total: Int
let onSelection: (Int) -> Void
var body: some View {
HStack(spacing: 8) {
ForEach(0 ..< total, id: \.self) { index in
Circle()
.foregroundStyle(
index == current
? AffineColors.buttonPrimary.color
: AffineColors.textSecondary.color.opacity(0.5)
)
.frame(width: 6, height: 6)
.padding(4)
.contentShape(Rectangle())
.onTapGesture {
onSelection(index)
}
}
}
}
}
#Preview {
VStack(spacing: 32) {
PageDotsView(current: 0, total: 8) { _ in }
PageDotsView(current: 1, total: 8) { _ in }
PageDotsView(current: 2, total: 8) { _ in }
PageDotsView(current: 3, total: 8) { _ in }
PageDotsView(current: 4, total: 8) { _ in }
PageDotsView(current: 5, total: 8) { _ in }
PageDotsView(current: 6, total: 8) { _ in }
PageDotsView(current: 7, total: 8) { _ in }
PageDotsView(current: 8, total: 8) { _ in }
}
.padding()
}

View File

@@ -0,0 +1,134 @@
//
// PricingOptionView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct PricingOptionView: View {
let price: String
let description: String
var badge: String
let isSelected: Bool
let action: () -> Void
init(
price: String,
description: String,
badge: String = "",
isSelected: Bool,
action: @escaping () -> Void = {}
) {
self.price = price
self.description = description
self.badge = badge
self.isSelected = isSelected
self.action = action
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(price)
.contentTransition(.numericText())
.font(.system(size: 20, weight: .bold))
.lineLimit(1)
.foregroundColor(isSelected ? AffineColors.buttonPrimary.color : AffineColors.textPrimary.color)
}
.layoutPriority(.infinity)
Spacer(minLength: 0)
if !badge.isEmpty {
Text(badge)
.contentTransition(.numericText())
.font(.system(size: 12))
.bold()
.lineLimit(1)
.foregroundColor(AffineColors.layerPureWhite.color)
.padding(2)
.padding(.horizontal, 2)
.background(AffineColors.buttonPrimary.color)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
if !description.isEmpty {
Text(description)
.contentTransition(.numericText())
.foregroundColor(isSelected ? AffineColors.buttonPrimary.color : AffineColors.textSecondary.color)
.font(.system(size: 14))
}
}
.animation(.interactiveSpring, value: price)
.animation(.interactiveSpring, value: description)
.animation(.interactiveSpring, value: badge)
.padding(12)
.frame(maxWidth: .infinity)
.background {
ZStack {
Rectangle()
.foregroundColor(AffineColors.layerBackgroundPrimary.color)
if isSelected {
Rectangle()
.foregroundColor(AffineColors.buttonPrimary.color)
.opacity(0.05)
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay {
if isSelected {
RoundedRectangle(cornerRadius: 8)
.stroke(AffineColors.buttonPrimary.color, lineWidth: 1.5)
.foregroundColor(.clear)
} else {
RoundedRectangle(cornerRadius: 8)
.stroke(AffineColors.layerBorder.color.opacity(0.15), lineWidth: 1.5)
.foregroundColor(.clear)
}
}
.shadow(color: AffineColors.layerBorder.color.opacity(0.05), radius: 4, x: 0, y: 0)
.animation(.interactiveSpring, value: isSelected)
.contentShape(.rect)
.onTapGesture {
action()
}
}
}
#Preview {
VStack(spacing: 16) {
HStack(spacing: 16) {
PricingOptionView(
price: "$7.99",
description: "Monthly",
isSelected: false
) {}
PricingOptionView(
price: "$6.75",
description: "Annually",
badge: "Save 15%",
isSelected: true
) {}
}
HStack(spacing: 16) {
PricingOptionView(
price: "$114514",
description: "Monthly",
badge: "Most Popular",
isSelected: true
) {}
PricingOptionView(
price: "$6.75",
description: "Annually",
badge: "Save 15%",
isSelected: false
) {}
}
}
.padding(16)
.background(Color.gray.opacity(0.25).ignoresSafeArea())
}

View File

@@ -0,0 +1,51 @@
//
// PurchaseFooterView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct PurchaseFooterView: View {
@StateObject var viewModel: ViewModel
var body: some View {
VStack(spacing: 16) {
if viewModel.availablePricingOptions.count > 1 {
HStack(spacing: 8) {
ForEach(viewModel.availablePricingOptions) { option in
PricingOptionView(
price: option.price,
description: option.description,
badge: option.badge ?? "",
isSelected: option.id == viewModel.selectedPricingIdentifier
) {
viewModel.select(pricingOption: option)
}
}
}
}
TheGiveMeMoneyButtonView(
primaryTitle: viewModel.selectedPricingOption.primaryTitle,
secondaryTitle: viewModel.selectedPricingOption.secondaryTitle,
callback: viewModel.purchase
)
Button(action: viewModel.restore) {
Text("Restore Purchase")
}
.font(.system(size: 12))
.buttonStyle(.plain)
.foregroundStyle(AffineColors.textSecondary.color)
}
}
}
#Preview {
PurchaseFooterView(viewModel: .init())
.padding()
.background(Color.gray.opacity(0.25).ignoresSafeArea())
}

View File

@@ -0,0 +1,76 @@
//
// TheGiveMeMoneyButtonView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct TheGiveMeMoneyButtonView: View {
let primaryTitle: String
let secondaryTitle: String
let callback: () -> Void
init(
primaryTitle: String = "",
secondaryTitle: String = "",
callback: @escaping () -> Void = {}
) {
self.primaryTitle = primaryTitle
self.secondaryTitle = secondaryTitle
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())
}
}
.foregroundColor(AffineColors.layerPureWhite.color)
.padding(12)
}
.animation(.spring, value: primaryTitle)
.animation(.spring, value: secondaryTitle)
.buttonStyle(.plain)
.frame(maxWidth: .infinity)
.background(AffineColors.buttonPrimary.color)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - Preview
#Preview {
VStack(spacing: 16) {
TheGiveMeMoneyButtonView(
primaryTitle: "Upgrade for $6.75 per month",
secondaryTitle: ""
)
TheGiveMeMoneyButtonView(
primaryTitle: "Upgrade for $10 per month",
secondaryTitle: ""
)
TheGiveMeMoneyButtonView(
primaryTitle: "$8.9 per month",
secondaryTitle: "billed annually"
)
TheGiveMeMoneyButtonView(
primaryTitle: "Upgrade for $499",
secondaryTitle: ""
)
}
.padding(32)
}

View File

@@ -0,0 +1,53 @@
//
// ProFeatureRowView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct ProFeatureRowView: View {
let feature: Feature
let index: Int
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "checkmark")
.font(.system(size: 16))
.foregroundColor(AffineColors.buttonPrimary.color)
Text(feature.text)
.font(.system(size: 16))
.contentTransition(.numericText())
.foregroundColor(feature.isHighlighted ? AffineColors.buttonPrimary.color : AffineColors.textPrimary.color)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.transition(.opacity)
}
}
#Preview {
VStack(alignment: .leading, spacing: 16) {
Divider()
ProFeatureRowView(
feature: .init(
"Hello World Feature Row View",
isHighlighted: true
),
index: 0
)
Divider()
ProFeatureRowView(
feature: .init("Hello World Feature Row View"),
index: 0
)
Divider()
}
.padding()
.background(Color.gray.opacity(0.25).ignoresSafeArea())
}

View File

@@ -0,0 +1,65 @@
//
// 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

@@ -0,0 +1,56 @@
//
// SKUnitProDetailView.swift
// AffinePaywall
//
// Created by qaq on 9/17/25.
//
import AffineResources
import SwiftUI
struct SKUnitProDetailView: View {
@StateObject var viewModel: ViewModel
@State var selection: SKUnitSubcategoryProPlan = .default
var body: some View {
VStack(spacing: 24) {
Picker("Plan", selection: $selection) {
ForEach(SKUnitSubcategoryProPlan.allCases) { plan in
Text(plan.title).tag(plan)
}
}
.pickerStyle(.segmented)
.onChange(of: selection) { _ in
viewModel.select(subcategory: selection)
}
HeadlineView(viewModel: viewModel)
ScrollView {
ProFeaturesCardView(
features: selection.features,
headerText: selection.headerText
)
.padding(16)
}
.padding(-16)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .top
)
}
}
}
#Preview {
SKUnitProDetailView(viewModel: .vmPreviewForPro)
.padding()
.background(
AffineColors.layerBackgroundSecondary
.color
.ignoresSafeArea()
)
.background(Color.gray.opacity(0.25).ignoresSafeArea())
}

View File

@@ -0,0 +1,19 @@
//
// Feature.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
struct Feature: Identifiable, Equatable {
var id = UUID()
var text: String
var isHighlighted: Bool // For text like "Everything in AFFINE Pro"
init(_ text: String, isHighlighted: Bool = false) {
self.text = text
self.isHighlighted = isHighlighted
}
}

View File

@@ -0,0 +1,27 @@
//
// SKUnit+AI.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
extension SKUnit {
static let aiUnits: [SKUnit] = [
SKUnit(
category: SKUnitCategory.ai,
primaryText: "AFFINE AI",
secondaryText: "A true multimodal AI copilot.",
pricing: [
SKUnitPricingOption(
price: "$8.9 per month",
description: "",
isDefaultSelected: true,
primaryTitle: "$8.9 per month",
secondaryTitle: "billed annually"
),
]
),
]
}

View File

@@ -0,0 +1,27 @@
//
// 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

@@ -0,0 +1,82 @@
//
// SKUnit+Pro.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
extension SKUnit {
static let proUnits: [SKUnit] = [
SKUnit(
category: SKUnitCategory.pro,
subcategory: SKUnitSubcategoryProPlan.default,
primaryText: "Pro",
secondaryText: "For family and small teams.",
pricing: [
SKUnitPricingOption(
price: "$7.99",
description: "Monthly",
isDefaultSelected: false,
primaryTitle: "Upgrade for $7.99/month",
secondaryTitle: ""
),
SKUnitPricingOption(
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: ""
),
]
),
]
}

View File

@@ -0,0 +1,55 @@
//
// SKUnit.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
struct SKUnit: Identifiable, Sendable {
let id = UUID()
let category: SKUnitCategory
let subcategory: any SKUnitSubcategorizable
let primaryText: String
let secondaryText: String
let pricing: [SKUnitPricingOption]
init(
category: SKUnitCategory,
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single,
primaryText: String,
secondaryText: String,
pricing: [SKUnitPricingOption]
) {
self.category = category
self.subcategory = subcategory
self.primaryText = primaryText
self.secondaryText = secondaryText
self.pricing = pricing
}
}
extension SKUnit {
static let allUnits: [SKUnit] = [
proUnits,
aiUnits,
believerUnits,
].flatMap(\.self)
static func units(for category: SKUnitCategory) -> [SKUnit] {
allUnits.filter { $0.category == category }
}
static func unit(
for type: SKUnitCategory,
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single
) -> SKUnit? {
let subcategory = subcategory.subcategoryIdentifier
let item = allUnits
.filter { $0.category == type }
.filter { $0.subcategory.subcategoryIdentifier == subcategory }
assert(item.count == 1)
return item.first
}
}

View File

@@ -0,0 +1,26 @@
//
// SKUnitCategory.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
var id: Int { rawValue }
case pro
case ai
case believer
}
extension SKUnitCategory {
var title: String {
switch self {
case .pro: "AFFINE.Pro"
case .ai: "AI"
case .believer: "Believer"
}
}
}

View File

@@ -0,0 +1,40 @@
//
// SKUnitPricingOption.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
struct SKUnitPricingOption: Identifiable, Equatable {
var id: UUID
// pricing selection button
var price: String
var description: String
var badge: String?
var isDefaultSelected: Bool
// subscribe button titles
var primaryTitle: String
var secondaryTitle: String
init(
id: UUID = UUID(),
price: String,
description: String,
badge: String? = nil,
isDefaultSelected: Bool = false,
primaryTitle: String,
secondaryTitle: String
) {
self.id = id
self.price = price
self.description = description
self.badge = badge
self.isDefaultSelected = isDefaultSelected
self.primaryTitle = primaryTitle
self.secondaryTitle = secondaryTitle
}
}

View File

@@ -0,0 +1,27 @@
//
// SKUnitSubcategorizable.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
protocol SKUnitSubcategorizable: Identifiable, Equatable, Hashable, CaseIterable, Sendable {
var id: String { get }
var subcategoryIdentifier: String { get }
}
extension SKUnitSubcategorizable {
var id: String {
subcategoryIdentifier
}
}
extension SKUnitSubcategorizable where Self: RawRepresentable, Self.RawValue == String {
var subcategoryIdentifier: String { rawValue }
}
enum SKUnitSingleSubcategory: String, SKUnitSubcategorizable {
case single
}

View File

@@ -0,0 +1,79 @@
//
// SKUnitSubcategoryProPlan.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
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"
}
}
var description: String {
switch self {
case .default:
"For family and small teams."
case .team:
"Best for scalable teams."
case .selfHost:
"Best for scalable teams."
}
}
}
extension SKUnitSubcategoryProPlan {
var headerText: String {
switch self {
case .default:
"Include in Pro"
case .team:
"Include in Team Workspace"
case .selfHost:
"Both in Teams & Enterprise"
}
}
var features: [Feature] {
switch self {
case .default:
[
Feature("Everything in AFFINE FOSS & Basic."),
Feature("100 GB of Cloud Storage"),
Feature("100 MB of Maximum file size"),
Feature("Up to 10 members per Workspace"),
Feature("30-days Cloud Time Machine file version history"),
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,31 @@
//
// ViewModel+Action.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
extension ViewModel {
func purchase() {
let unit = selectedUnit
let option = selectedPricingOption
print(#function, unit, option)
}
func restore() {
let unit = selectedUnit
let option = selectedPricingOption
print(#function, unit, option)
}
func dismiss() {
let unit = selectedUnit
let option = selectedPricingOption
print(#function, unit, option)
}
}

View File

@@ -0,0 +1,29 @@
//
// ViewModel+Preview.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
extension ViewModel {
static let vmPreviewForPro: ViewModel = {
let vm = ViewModel()
vm.select(category: .pro)
vm.select(subcategory: SKUnitSubcategoryProPlan.default)
return vm
}()
static let vmPreviewForAI: ViewModel = {
let vm = ViewModel()
vm.select(category: .ai)
return vm
}()
static let vmPreviewForBeliever: ViewModel = {
let vm = ViewModel()
vm.select(category: .believer)
return vm
}()
}

View File

@@ -0,0 +1,99 @@
//
// ViewModel.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import SwiftUI
@MainActor
class ViewModel: ObservableObject {
var availableUnits: [SKUnit] {
SKUnit.units(for: category)
}
@Published private(set) var category: SKUnitCategory = .pro
@Published private(set) var subcategory: any SKUnitSubcategorizable = SKUnitSubcategoryProPlan.default
@Published private(set) var selectedPricingIdentifier: UUID = SKUnit.unit(
for: .pro,
subcategory: SKUnitSubcategoryProPlan.default
)!.pricing.first { $0.isDefaultSelected }!.id
init() {}
func select(category: SKUnitCategory) {
self.category = category
let units = SKUnit.units(for: category)
let subcategoryExists = units
.contains { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }
if !subcategoryExists {
subcategory = units.first!.subcategory
}
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
}
func select(subcategory: any SKUnitSubcategorizable) {
let units = SKUnit.units(for: category)
let subcategoryExists = units
.contains { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }
if !subcategoryExists {
let category = availableUnits
.first { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }!
.category
self.category = category
} else {
self.subcategory = subcategory
}
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
}
func select(pricingOption option: SKUnitPricingOption) {
selectedPricingIdentifier = option.id
let unit = availableUnits
.first { unit in
unit.pricing.contains { $0.id == option.id }
}!
category = unit.category
subcategory = unit.subcategory
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
}
}
@MainActor
extension ViewModel {
var selectedUnit: SKUnit {
if let unit = SKUnit.unit(for: category, subcategory: subcategory) {
return unit
}
let units = SKUnit.units(for: category)
if let last = units.last {
subcategory = last.subcategory
return last
}
let item = availableUnits.first!
category = item.category
subcategory = item.subcategory
return item
}
var selectedPricingOption: SKUnitPricingOption {
let item = selectedUnit.pricing
.first { $0.id == selectedPricingIdentifier }
if let item { return item }
let defaultItem = selectedUnit.pricing.first { $0.isDefaultSelected }
if let defaultItem {
selectedPricingIdentifier = defaultItem.id
return defaultItem
}
let lastItem = selectedUnit.pricing.last!
selectedPricingIdentifier = lastItem.id
return lastItem
}
var availablePricingOptions: [SKUnitPricingOption] {
selectedUnit.pricing
}
}

View File

@@ -0,0 +1,77 @@
//
// AffinePaywallPageView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct AffinePaywallPageView: View {
@StateObject var viewModel = ViewModel()
@Environment(\.dismiss) var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
CategorySelectionView(
selectedTab: viewModel.category,
onSelect: viewModel.select(category:)
)
Spacer()
Button {
viewModel.dismiss()
} label: {
Image(AffineIcons.close.rawValue)
}
.buttonStyle(.plain)
.foregroundColor(AffineColors.textSecondary.color)
}
ZStack(alignment: .topLeading) {
Spacer()
.frame(maxWidth: .infinity, maxHeight: .infinity)
content
.frame(maxWidth: .infinity)
.transition(
.opacity
.combined(with: .scale(
scale: 0.95,
anchor: .init(x: 0.5, y: 0)
))
)
}
.animation(.spring.speed(2), value: viewModel.category)
PurchaseFooterView(viewModel: viewModel)
.animation(.spring.speed(2), value: viewModel.selectedPricingIdentifier)
}
.padding()
.background(
AffineColors.layerBackgroundSecondary.color
)
}
@ViewBuilder
var content: some View {
switch viewModel.category {
case .pro:
SKUnitProDetailView(viewModel: viewModel)
case .ai:
SKUnitIntelligentDetailView(viewModel: viewModel)
case .believer:
SKUnitBelieverDetailView(viewModel: viewModel)
}
}
}
#Preview {
struct PreviewWrapper: View {
@StateObject var viewModel = ViewModel()
var body: some View {
AffinePaywallPageView(viewModel: viewModel)
}
}
return PreviewWrapper()
}

View File

@@ -0,0 +1,30 @@
//
// File.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import UIKit
import SwiftUI
public enum Paywall {
@MainActor
public static func presentWall(
toController controller: UIViewController,
type: String
) {
let viewModel = ViewModel()
switch type {
// TODO: FIGURE OUT PAYWALL TYPES
default:
break
}
let view = AffinePaywallPageView(viewModel: viewModel)
let hostingController = UIHostingController(rootView: view)
hostingController.modalPresentationStyle = .overFullScreen
hostingController.modalTransitionStyle = .coverVertical
hostingController.preferredContentSize = CGSize(width: 555, height: 555) // for iPads
controller.present(hostingController, animated: true)
}
}

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 3.25C4.48122 3.25 3.25 4.48122 3.25 6V18C3.25 19.5188 4.48122 20.75 6 20.75H18C19.5188 20.75 20.75 19.5188 20.75 18V6C20.75 4.48122 19.5188 3.25 18 3.25H6ZM4.75 6C4.75 5.30964 5.30964 4.75 6 4.75H18C18.6904 4.75 19.25 5.30964 19.25 6V18C19.25 18.6904 18.6904 19.25 18 19.25H6C5.30964 19.25 4.75 18.6904 4.75 18V6ZM16.5303 9.53033C16.8232 9.23744 16.8232 8.76256 16.5303 8.46967C16.2374 8.17678 15.7626 8.17678 15.4697 8.46967L10.5 13.4393L9.03033 11.9697C8.73744 11.6768 8.26256 11.6768 7.96967 11.9697C7.67678 12.2626 7.67678 12.7374 7.96967 13.0303L9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L16.5303 9.53033Z" fill="#1E96EB"/>
</svg>

After

Width:  |  Height:  |  Size: 806 B

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AI_CHECK.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,4 @@
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7786 4.72105C19.1493 4.09298 18.1284 4.09298 17.4991 4.72105L16.6144 5.60408L18.8918 7.87702L19.7786 6.9919C20.4071 6.36465 20.4071 5.3483 19.7786 4.72105ZM17.8301 8.93664L15.5526 6.6637L4.75 17.4451V19.7501H6.99534L17.8301 8.93664ZM16.4395 3.65934C17.6544 2.44689 19.6234 2.44689 20.8383 3.65934C22.0539 4.87262 22.0539 6.84033 20.8383 8.05361L7.83537 21.0309C7.69476 21.1712 7.50422 21.2501 7.30557 21.2501H4C3.58579 21.2501 3.25 20.9143 3.25 20.5001V17.134C3.25 16.9348 3.32922 16.7438 3.47019 16.6032L16.4395 3.65934Z" fill="#1E96EB"/>
</svg>

After

Width:  |  Height:  |  Size: 697 B

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AI_PEN.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "AI_PREVIEW_B.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "AI_PREVIEW_B 1.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "AI_PREVIEW_C.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "AI_PREVIEW_C 1.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "AI_PREVIEW_A 1.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "AI_PREVIEW_A.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,4 @@
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.75 4C3.75 3.58579 4.08579 3.25 4.5 3.25H20.5C20.9142 3.25 21.25 3.58579 21.25 4V6.66667C21.25 7.08088 20.9142 7.41667 20.5 7.41667C20.0858 7.41667 19.75 7.08088 19.75 6.66667V4.75H13.25V19.25H16.5C16.9142 19.25 17.25 19.5858 17.25 20C17.25 20.4142 16.9142 20.75 16.5 20.75H8.5C8.08579 20.75 7.75 20.4142 7.75 20C7.75 19.5858 8.08579 19.25 8.5 19.25H11.75V4.75H5.25V6.66667C5.25 7.08088 4.91421 7.41667 4.5 7.41667C4.08579 7.41667 3.75 7.08088 3.75 6.66667V4Z" fill="#1E96EB"/>
</svg>

After

Width:  |  Height:  |  Size: 633 B

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AI_TEXT.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "Image.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "BELIVER_ICON.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,16 @@
//
// 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()
}
}