feat(ios): polish onboarding and native sign-in flow (#15217)
## Summary - Reworks the iOS onboarding, native sign-in, and paywall flow so first-install and cold-start entry stay native, adaptive, and aligned with the current login/subscription gates. - Removes onboarding swipe paging, refines onboarding/paywall layout behavior, and keeps AI/paywall entry behavior consistent for logged-in and subscribed users. - Adds the new mobile all-docs empty states with localized copy and dialog entry points, and closes the remaining review follow-ups by removing the onboarding plan artifact and dropping the iOS AI subscription bypass. ## Test plan - Built the iOS app for simulator with `xcodebuild -workspace App.xcworkspace -scheme App -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' ARCHS=arm64 ONLY_ACTIVE_ARCH=YES CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build` during the native onboarding/sign-in flow work. - Let the repo pre-commit hooks run on the latest follow-up commit (`prettier` + `eslint --fix`). - Checked diagnostics for the edited TS/TSX files after the review follow-up changes. - Manually iterated on onboarding/native sign-in/paywall UI states in simulator during implementation. --------- Co-authored-by: DarkSky <darksky2048@gmail.com>
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
public final class AuthAccessTokenCache: @unchecked Sendable {
|
||||
public static let shared = AuthAccessTokenCache()
|
||||
|
||||
private struct Entry {
|
||||
let token: String
|
||||
let expiresAt: Date
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var entries: [String: Entry] = [:]
|
||||
|
||||
private init() {}
|
||||
|
||||
public func set(_ token: String, expiresAt: Date, for endpoint: String) {
|
||||
guard let key = Self.canonicalOrigin(endpoint) else { return }
|
||||
lock.lock()
|
||||
entries[key] = Entry(token: token, expiresAt: expiresAt)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
public func token(for endpoint: URL, minimumValidity: TimeInterval = 0) -> String? {
|
||||
token(for: endpoint, matching: nil, minimumValidity: minimumValidity)
|
||||
}
|
||||
|
||||
public func token(
|
||||
for endpoint: URL,
|
||||
matching origin: URL?,
|
||||
minimumValidity: TimeInterval = 0
|
||||
) -> String? {
|
||||
guard let key = Self.canonicalOrigin(endpoint) else { return nil }
|
||||
if let origin, Self.canonicalOrigin(origin) != key { return nil }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
guard let entry = entries[key], entry.expiresAt.timeIntervalSinceNow > minimumValidity else {
|
||||
return nil
|
||||
}
|
||||
return entry.token
|
||||
}
|
||||
|
||||
public func remove(for endpoint: String) {
|
||||
guard let key = Self.canonicalOrigin(endpoint) else { return }
|
||||
lock.lock()
|
||||
entries[key] = nil
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
private static func canonicalOrigin(_ endpoint: String) -> String? {
|
||||
guard let url = URL(string: endpoint) else { return nil }
|
||||
return canonicalOrigin(url)
|
||||
}
|
||||
|
||||
private static func canonicalOrigin(_ url: URL) -> String? {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let defaultPort = scheme == "https" ? 443 : (scheme == "http" ? 80 : nil)
|
||||
if let port = components.port, port != defaultPort {
|
||||
return "\(scheme)://\(host):\(port)"
|
||||
}
|
||||
return "\(scheme)://\(host)"
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,12 @@ let package = Package(
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../AffineResources"),
|
||||
.package(url: "https://github.com/RevenueCat/purchases-ios-spm.git", from: "5.83.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "AffinePaywall",
|
||||
dependencies: [
|
||||
"AffineResources",
|
||||
.product(name: "RevenueCat", package: "purchases-ios-spm"),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//
|
||||
// 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",
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
//
|
||||
// SKUnitIntelligentDetailView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
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)
|
||||
.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
|
||||
OffsetObservingScrollView(
|
||||
axes: .horizontal,
|
||||
showsIndicators: false,
|
||||
offset: $scrollOffset
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: height)
|
||||
.onChange(of: detailIndex) { newValue in
|
||||
withAnimation(.spring) {
|
||||
scrollView.scrollTo(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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(
|
||||
current: detailIndex,
|
||||
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
|
||||
if Date().timeIntervalSince(lastInteractionDate) > 5 {
|
||||
detailIndex = (detailIndex + 1) % Self.features.count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SKUnitIntelligentDetailView(viewModel: .vmPreviewForAI)
|
||||
.padding()
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
//
|
||||
// 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 })
|
||||
Divider()
|
||||
PreviewWrapper()
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.25).ignoresSafeArea())
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//
|
||||
// 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: .semibold))
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
//
|
||||
// PackageOptionView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct PackageOptionView: 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: 10))
|
||||
.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) {
|
||||
PackageOptionView(
|
||||
price: "$7.99",
|
||||
description: "Monthly",
|
||||
isSelected: false
|
||||
) {}
|
||||
PackageOptionView(
|
||||
price: "$6.75",
|
||||
description: "Annually",
|
||||
badge: "Save 15%",
|
||||
isSelected: true
|
||||
) {}
|
||||
}
|
||||
HStack(spacing: 16) {
|
||||
PackageOptionView(
|
||||
price: "$114514",
|
||||
description: "Monthly",
|
||||
badge: "Most Popular",
|
||||
isSelected: true
|
||||
) {}
|
||||
PackageOptionView(
|
||||
price: "$6.75",
|
||||
description: "Annually",
|
||||
badge: "Save 15%",
|
||||
isSelected: false
|
||||
) {}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.gray.opacity(0.25).ignoresSafeArea())
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//
|
||||
// PurchaseFooterView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
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.availablePackageOptions.count > 1 {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(viewModel.availablePackageOptions) { option in
|
||||
PackageOptionView(
|
||||
price: option.price,
|
||||
description: option.description,
|
||||
badge: option.badge ?? "",
|
||||
isSelected: option.id == viewModel.selectedPackageIdentifier
|
||||
) {
|
||||
viewModel.select(packageOption: option)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isPurchased)
|
||||
}
|
||||
|
||||
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) {
|
||||
if isPurchased {
|
||||
Text("Already Purchased")
|
||||
} else {
|
||||
Text("Restore Purchase")
|
||||
.underline()
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(AffineColors.textSecondary.color)
|
||||
.opacity(viewModel.products.isEmpty ? 0 : 1)
|
||||
.disabled(isPurchased)
|
||||
.disabled(viewModel.updating)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PurchaseFooterView(viewModel: .init())
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.25).ignoresSafeArea())
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
//
|
||||
// TheGiveMeMoneyButtonView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
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: {
|
||||
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)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 16) {
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $6.75 per month",
|
||||
secondaryTitle: "",
|
||||
isPurchased: false
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $10 per month",
|
||||
secondaryTitle: "",
|
||||
isPurchased: false
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "$8.9 per month",
|
||||
secondaryTitle: "billed annually",
|
||||
isPurchased: false
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $499",
|
||||
secondaryTitle: "",
|
||||
isPurchased: false
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $499",
|
||||
secondaryTitle: "",
|
||||
isPurchased: true
|
||||
)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
//
|
||||
// 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())
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
@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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
HeadlineView(viewModel: viewModel)
|
||||
|
||||
ScrollView {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SKUnitProDetailView(viewModel: .vmPreviewForPro)
|
||||
.padding()
|
||||
.background(
|
||||
AffineColors.layerBackgroundSecondary
|
||||
.color
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
.background(Color.gray.opacity(0.25).ignoresSafeArea())
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//
|
||||
// Feature.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Feature: Identifiable, Equatable, Hashable {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
//
|
||||
// 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.",
|
||||
package: [
|
||||
SKUnitPackageOption(
|
||||
price: "...", // Will be populated from App Store
|
||||
description: "",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "...", // Will be populated from App Store
|
||||
secondaryTitle: "",
|
||||
productIdentifier: "app.affine.pro.ai.Annual",
|
||||
revenueCatIdentifier: "app.affine.pro.ai.Annual"
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//
|
||||
// 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.",
|
||||
package: [
|
||||
SKUnitPackageOption(
|
||||
price: "...", // Will be populated from App Store
|
||||
description: PricingConfiguration.proMonthly.description,
|
||||
isDefaultSelected: PricingConfiguration.proMonthly.isDefaultSelected,
|
||||
primaryTitle: "...", // Will be populated from App Store
|
||||
secondaryTitle: "",
|
||||
productIdentifier: PricingConfiguration.proMonthly.productIdentifier,
|
||||
revenueCatIdentifier: PricingConfiguration.proMonthly.revenueCatIdentifier
|
||||
),
|
||||
SKUnitPackageOption(
|
||||
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: PricingConfiguration.proAnnual.productIdentifier,
|
||||
revenueCatIdentifier: PricingConfiguration.proAnnual.revenueCatIdentifier
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
//
|
||||
// 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 package: [SKUnitPackageOption]
|
||||
|
||||
init(
|
||||
category: SKUnitCategory,
|
||||
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single,
|
||||
primaryText: String,
|
||||
secondaryText: String,
|
||||
package: [SKUnitPackageOption]
|
||||
) {
|
||||
self.category = category
|
||||
self.subcategory = subcategory
|
||||
self.primaryText = primaryText
|
||||
self.secondaryText = secondaryText
|
||||
self.package = package
|
||||
}
|
||||
}
|
||||
|
||||
extension SKUnit {
|
||||
static let allUnits: [SKUnit] = [
|
||||
proUnits,
|
||||
aiUnits,
|
||||
].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
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//
|
||||
// SKUnitCategory.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable, Sendable {
|
||||
public var id: Int { rawValue }
|
||||
|
||||
case pro
|
||||
case ai
|
||||
}
|
||||
|
||||
public extension SKUnitCategory {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pro: "AFFINE.Pro"
|
||||
case .ai: "AI"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//
|
||||
// SKUnitPackageOption.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SKUnitPackageOption: Identifiable, Equatable {
|
||||
var id: UUID
|
||||
|
||||
// package selection button
|
||||
var price: String
|
||||
var description: String
|
||||
var badge: String?
|
||||
var isDefaultSelected: Bool
|
||||
|
||||
// subscribe button titles
|
||||
var primaryTitle: String
|
||||
var secondaryTitle: String
|
||||
|
||||
// product identifiers
|
||||
var productIdentifier: String
|
||||
var revenueCatIdentifier: String
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
price: String,
|
||||
description: String,
|
||||
badge: String? = nil,
|
||||
isDefaultSelected: Bool = false,
|
||||
primaryTitle: String,
|
||||
secondaryTitle: String,
|
||||
productIdentifier: String,
|
||||
revenueCatIdentifier: String
|
||||
) {
|
||||
self.id = id
|
||||
self.price = price
|
||||
self.description = description
|
||||
self.badge = badge
|
||||
self.isDefaultSelected = isDefaultSelected
|
||||
self.primaryTitle = primaryTitle
|
||||
self.secondaryTitle = secondaryTitle
|
||||
self.productIdentifier = productIdentifier
|
||||
self.revenueCatIdentifier = revenueCatIdentifier
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//
|
||||
// SKUnitSubcategoryProPlan.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable {
|
||||
case `default`
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .default: "Pro"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .default:
|
||||
"For family and small teams."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SKUnitSubcategoryProPlan {
|
||||
var headerText: String {
|
||||
switch self {
|
||||
case .default:
|
||||
"Include in Pro"
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//
|
||||
// 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)")
|
||||
#if DEBUG
|
||||
try await Task.sleep(for: .seconds(1)) // simulate network delay
|
||||
#endif
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
//
|
||||
// ViewModel+Action.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RevenueCat
|
||||
import UIKit
|
||||
|
||||
extension ViewModel {
|
||||
func purchase() {
|
||||
let unit = selectedUnit
|
||||
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(verificationResult):
|
||||
switch verificationResult {
|
||||
case .verified(let transaction):
|
||||
print("purchase success", transaction)
|
||||
try await self.applySubscription(transactionID: .init(transaction.id))
|
||||
case .unverified:
|
||||
#if DEBUG
|
||||
break
|
||||
#else
|
||||
throw NSError() // should not happening
|
||||
#endif
|
||||
}
|
||||
shouldDismiss = true
|
||||
case .userCancelled:
|
||||
break
|
||||
@unknown default:
|
||||
assertionFailure()
|
||||
}
|
||||
} else { assertionFailure() } // should never happen
|
||||
|
||||
await MainActor.run {
|
||||
self.updating = false
|
||||
}
|
||||
if shouldDismiss {
|
||||
await MainActor.run {
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restore() {
|
||||
let unit = selectedUnit
|
||||
let option = selectePackageOption
|
||||
|
||||
assert(!updating)
|
||||
guard !updating else { return }
|
||||
print(#function, unit, option)
|
||||
|
||||
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() {
|
||||
print(#function)
|
||||
|
||||
if let context = associatedWebContext {
|
||||
Task.detached {
|
||||
do {
|
||||
_ = try await context.callAsyncJavaScript(
|
||||
"return await window.updateSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
print("updateSubscriptionState success")
|
||||
} catch {
|
||||
print("updateSubscriptionState error:", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
associatedController?.dismiss(animated: true)
|
||||
}
|
||||
|
||||
func applySubscription(transactionID: String) async throws {
|
||||
print(#function, transactionID)
|
||||
|
||||
if let context = associatedWebContext {
|
||||
_ = try await context.callAsyncJavaScript(
|
||||
"return await window.requestApplySubscription('\(transactionID)');",
|
||||
contentWorld: .page
|
||||
)
|
||||
print("requestApplySubscription success")
|
||||
} else {
|
||||
assertionFailure()
|
||||
throw NSError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// now we fetch records from app store
|
||||
let products = try await store.fetchProducts()
|
||||
await MainActor.run {
|
||||
self.products = products
|
||||
self.updatePackageOptions(with: products)
|
||||
}
|
||||
|
||||
// fetch purchased items if signed in
|
||||
do {
|
||||
let purchase = try await store.fetchEntitlements()
|
||||
await MainActor.run { self.storePurchasedItems = purchase }
|
||||
} catch {
|
||||
print("fetchEntitlements error:", error)
|
||||
if !initial { throw error }
|
||||
}
|
||||
|
||||
guard let webView = await associatedWebContext else {
|
||||
throw NSError(domain: "Paywall", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
|
||||
])
|
||||
}
|
||||
|
||||
// fetch current user identifier
|
||||
do {
|
||||
let result = try await webView.callAsyncJavaScript(
|
||||
"return await window.getCurrentUserIdentifier();",
|
||||
contentWorld: .page
|
||||
)
|
||||
let userIdentifier = (result as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
// for too long it might be a problem on front end returning what we dont want
|
||||
guard !userIdentifier.isEmpty, userIdentifier.count < 256 else {
|
||||
throw NSError(domain: "Paywall", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
|
||||
])
|
||||
}
|
||||
print("[*] using user identifier:", userIdentifier)
|
||||
let configuration = Configuration
|
||||
.builder(withAPIKey: Paywall.revenueCatToken)
|
||||
.with(appUserID: userIdentifier)
|
||||
.with(showStoreMessagesAutomatically: false)
|
||||
.build()
|
||||
Purchases.configure(with: configuration)
|
||||
_ = try? await Purchases.shared.logOut()
|
||||
let loginItem = try await Purchases.shared.logIn(userIdentifier)
|
||||
print("[*] log in to RevenueCat finished: \(loginItem)")
|
||||
} catch {
|
||||
print("unable to login with error:", error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
|
||||
// fetch external items by executing on webview's JS context
|
||||
do {
|
||||
let result = try await webView.callAsyncJavaScript(
|
||||
"return await window.getSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
let purchased = decodeWebContextSubscriptionInformation(result)
|
||||
print("fetched external purchased items:", purchased)
|
||||
await MainActor.run { self.externalPurchasedItems = purchased }
|
||||
} catch {
|
||||
print("fetchExternalEntitlements error:", error.localizedDescription)
|
||||
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 }
|
||||
}
|
||||
|
||||
nonisolated func decodeWebContextSubscriptionInformation(_ input: Any?) -> Set<String> {
|
||||
var ans: Set<String> = []
|
||||
|
||||
guard let dict = input as? [String: Any] else {
|
||||
assertionFailure()
|
||||
return ans
|
||||
}
|
||||
|
||||
let pro = dict["pro"] as? [String: Any]
|
||||
let ai = dict["ai"] as? [String: Any]
|
||||
|
||||
if let proPlan = pro?["recurring"] as? String {
|
||||
switch proPlan.lowercased() {
|
||||
case "lifetime":
|
||||
// user actually purchased believer plan
|
||||
// but we map it to yearly plan just for easier handling
|
||||
// do not purchase any of this plan if already purchased
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
|
||||
case "monthly":
|
||||
ans.insert(PricingConfiguration.proMonthly.productIdentifier)
|
||||
case "yearly":
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
|
||||
default:
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier) // block payment
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
if let aiPlan = ai?["recurring"] as? String {
|
||||
switch aiPlan.lowercased() {
|
||||
case "yearly":
|
||||
ans.insert(PricingConfiguration.aiAnnual.productIdentifier)
|
||||
default:
|
||||
// ai plan can only be purchased as yearly plan
|
||||
ans.insert(PricingConfiguration.aiAnnual.productIdentifier) // block payment
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
return ans
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}()
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
//
|
||||
// ViewModel.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
@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 selectedPackageIdentifier: UUID = SKUnit.unit(
|
||||
for: .pro,
|
||||
subcategory: SKUnitSubcategoryProPlan.default
|
||||
)!.package.first { $0.isDefaultSelected }!.id
|
||||
|
||||
@Published var updating = false
|
||||
@Published var products: [Product] = []
|
||||
@Published var storePurchasedItems: Set<String> = []
|
||||
@Published var externalPurchasedItems: Set<String> = []
|
||||
@Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package)
|
||||
|
||||
var purchasedItems: Set<String> {
|
||||
Set<String>()
|
||||
.union(storePurchasedItems)
|
||||
.union(externalPurchasedItems)
|
||||
}
|
||||
|
||||
private(set) weak var associatedController: UIViewController?
|
||||
private(set) weak var associatedWebContext: WKWebView?
|
||||
|
||||
init() {
|
||||
updateAppStoreStatus(initial: true)
|
||||
}
|
||||
|
||||
func updateAppStoreStatus(initial: Bool) {
|
||||
Task.detached {
|
||||
await self.updateAppStoreStatusExecute(initial: initial)
|
||||
}
|
||||
}
|
||||
|
||||
func bind(controller: UIViewController) {
|
||||
associatedController = controller
|
||||
}
|
||||
|
||||
func bind(context: WKWebView) {
|
||||
associatedWebContext = context
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
_ = selectePackageOption // ensure selectePackageOption 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
|
||||
}
|
||||
_ = selectePackageOption // ensure selectePackageOption is valid
|
||||
}
|
||||
|
||||
func select(packageOption option: SKUnitPackageOption) {
|
||||
selectedPackageIdentifier = option.id
|
||||
|
||||
let unit = availableUnits
|
||||
.first { unit in
|
||||
unit.package.contains { $0.id == option.id }
|
||||
}!
|
||||
category = unit.category
|
||||
subcategory = unit.subcategory
|
||||
|
||||
_ = 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
|
||||
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 selectePackageOption: SKUnitPackageOption {
|
||||
let unitPackageIds = selectedUnit.package.map(\.id)
|
||||
let item = packageOptions
|
||||
.first { $0.id == selectedPackageIdentifier && unitPackageIds.contains($0.id) }
|
||||
if let item { return item }
|
||||
let defaultItem = packageOptions
|
||||
.first { $0.isDefaultSelected && unitPackageIds.contains($0.id) }
|
||||
if let defaultItem {
|
||||
selectedPackageIdentifier = defaultItem.id
|
||||
return defaultItem
|
||||
}
|
||||
let lastItem = packageOptions
|
||||
.first { unitPackageIds.contains($0.id) }!
|
||||
selectedPackageIdentifier = lastItem.id
|
||||
return lastItem
|
||||
}
|
||||
|
||||
var availablePackageOptions: [SKUnitPackageOption] {
|
||||
let unitPackageIds = selectedUnit.package.map(\.id)
|
||||
return packageOptions.filter { unitPackageIds.contains($0.id) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
import RevenueCat
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
public enum NativePaywallPlanKind: String, CaseIterable, Sendable {
|
||||
case lite
|
||||
case pro
|
||||
case ai
|
||||
}
|
||||
|
||||
public struct NativePaywallPriceInfo: Equatable, Sendable {
|
||||
public let value: String
|
||||
public let suffix: String
|
||||
|
||||
public init(value: String, suffix: String) {
|
||||
self.value = value
|
||||
self.suffix = suffix
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class NativePaywallBridge: ObservableObject {
|
||||
@Published public private(set) var isLoading = false
|
||||
@Published public private(set) var isProcessing = false
|
||||
@Published public private(set) var priceInfoByPlan: [NativePaywallPlanKind: NativePaywallPriceInfo]
|
||||
@Published public private(set) var selectedPlan: NativePaywallPlanKind
|
||||
@Published public var errorMessage: String?
|
||||
|
||||
private weak var associatedWebContext: WKWebView?
|
||||
private var productsByPlan: [NativePaywallPlanKind: StoreProduct] = [:]
|
||||
|
||||
public init(initialPlan: NativePaywallPlanKind = .pro) {
|
||||
selectedPlan = initialPlan
|
||||
priceInfoByPlan = [:]
|
||||
}
|
||||
|
||||
public func bind(webView: WKWebView?) {
|
||||
associatedWebContext = webView
|
||||
}
|
||||
|
||||
public func selectPlan(_ plan: NativePaywallPlanKind) {
|
||||
selectedPlan = plan
|
||||
}
|
||||
|
||||
public var isReady: Bool {
|
||||
productsByPlan.count == NativePaywallPlanKind.allCases.count
|
||||
}
|
||||
|
||||
public func priceInfo(for plan: NativePaywallPlanKind) -> NativePaywallPriceInfo? {
|
||||
priceInfoByPlan[plan]
|
||||
}
|
||||
|
||||
public func prepare() async throws {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
guard associatedWebContext != nil else {
|
||||
throw bridgeError("Missing required information")
|
||||
}
|
||||
configurePurchases()
|
||||
|
||||
let products = await Purchases.shared.products(Self.productIdentifiers)
|
||||
try Task.checkCancellation()
|
||||
guard products.count == Self.productIdentifiers.count else {
|
||||
throw bridgeError("Unable to load subscription options right now.")
|
||||
}
|
||||
|
||||
var nextProducts: [NativePaywallPlanKind: StoreProduct] = [:]
|
||||
for plan in NativePaywallPlanKind.allCases {
|
||||
if let product = products.first(where: { $0.productIdentifier == Self.productIdentifier(for: plan) }) {
|
||||
nextProducts[plan] = product
|
||||
}
|
||||
}
|
||||
|
||||
guard nextProducts.count == NativePaywallPlanKind.allCases.count else {
|
||||
throw bridgeError("Unable to load subscription options right now.")
|
||||
}
|
||||
|
||||
productsByPlan = nextProducts
|
||||
|
||||
var nextPriceInfo: [NativePaywallPlanKind: NativePaywallPriceInfo] = [:]
|
||||
for plan in NativePaywallPlanKind.allCases {
|
||||
if let product = nextProducts[plan] {
|
||||
nextPriceInfo[plan] = Self.makePriceInfo(for: plan, product: product)
|
||||
}
|
||||
}
|
||||
priceInfoByPlan = nextPriceInfo
|
||||
}
|
||||
|
||||
public func purchaseSelectedPlan() async throws -> Bool {
|
||||
isProcessing = true
|
||||
defer { isProcessing = false }
|
||||
|
||||
guard let webView = associatedWebContext else {
|
||||
throw bridgeError("Missing required information")
|
||||
}
|
||||
try await configurePurchasesForCurrentUser(in: webView)
|
||||
|
||||
guard let product = productsByPlan[selectedPlan] else {
|
||||
throw bridgeError("Unable to load the selected plan.")
|
||||
}
|
||||
|
||||
let result = try await Purchases.shared.purchase(product: product)
|
||||
if result.userCancelled {
|
||||
return false
|
||||
}
|
||||
|
||||
try Task.checkCancellation()
|
||||
|
||||
if let transaction = result.transaction {
|
||||
try await applySubscription(transactionID: transaction.transactionIdentifier, in: webView)
|
||||
} else {
|
||||
try await updateSubscriptionState(in: webView)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
public func restorePurchases() async throws {
|
||||
isProcessing = true
|
||||
defer { isProcessing = false }
|
||||
|
||||
guard let webView = associatedWebContext else {
|
||||
throw bridgeError("Missing required information")
|
||||
}
|
||||
try await configurePurchasesForCurrentUser(in: webView)
|
||||
_ = try await Purchases.shared.restorePurchases()
|
||||
try await updateSubscriptionState(in: webView)
|
||||
}
|
||||
}
|
||||
|
||||
private extension NativePaywallBridge {
|
||||
static let revenueCatToken = "appl_FIzFhieVpSSmJRYJWwhVrgtnsVf"
|
||||
static let revenueCatProxyEndpoint = URL(string: "https://iap.affine.pro/")!
|
||||
static var isPurchasesConfigured = false
|
||||
|
||||
static let productIdentifiers: [String] = [
|
||||
PricingConfiguration.proMonthly.productIdentifier,
|
||||
PricingConfiguration.proAnnual.productIdentifier,
|
||||
PricingConfiguration.aiAnnual.productIdentifier,
|
||||
]
|
||||
|
||||
static func productIdentifier(for plan: NativePaywallPlanKind) -> String {
|
||||
switch plan {
|
||||
case .lite:
|
||||
PricingConfiguration.proMonthly.productIdentifier
|
||||
case .pro:
|
||||
PricingConfiguration.proAnnual.productIdentifier
|
||||
case .ai:
|
||||
PricingConfiguration.aiAnnual.productIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
func configurePurchases() {
|
||||
#if DEBUG
|
||||
Purchases.logLevel = .debug
|
||||
#endif
|
||||
Purchases.proxyURL = Self.revenueCatProxyEndpoint
|
||||
|
||||
if !Self.isPurchasesConfigured {
|
||||
let configuration = Configuration
|
||||
.builder(withAPIKey: Self.revenueCatToken)
|
||||
.with(showStoreMessagesAutomatically: false)
|
||||
.build()
|
||||
Purchases.configure(with: configuration)
|
||||
Self.isPurchasesConfigured = true
|
||||
}
|
||||
}
|
||||
|
||||
func configurePurchasesForCurrentUser(in webView: WKWebView) async throws {
|
||||
configurePurchases()
|
||||
let userIdentifier = try await fetchCurrentUserIdentifier(in: webView)
|
||||
_ = try await Purchases.shared.logIn(userIdentifier)
|
||||
}
|
||||
|
||||
func fetchCurrentUserIdentifier(in webView: WKWebView) async throws -> String {
|
||||
let result = try await webView.callAsyncJavaScript(
|
||||
"return await window.getCurrentUserIdentifier();",
|
||||
contentWorld: .page
|
||||
)
|
||||
let userIdentifier = (result as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
guard !userIdentifier.isEmpty, userIdentifier.count < 256 else {
|
||||
throw bridgeError("Missing required information")
|
||||
}
|
||||
|
||||
return userIdentifier
|
||||
}
|
||||
|
||||
func applySubscription(transactionID: String, in webView: WKWebView) async throws {
|
||||
_ = try await webView.callAsyncJavaScript(
|
||||
"return await window.requestApplySubscription(transactionID);",
|
||||
arguments: ["transactionID": transactionID],
|
||||
contentWorld: .page
|
||||
)
|
||||
}
|
||||
|
||||
func updateSubscriptionState(in webView: WKWebView) async throws {
|
||||
_ = try await webView.callAsyncJavaScript(
|
||||
"return await window.updateSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
}
|
||||
|
||||
static func makePriceInfo(for plan: NativePaywallPlanKind, product: StoreProduct) -> NativePaywallPriceInfo {
|
||||
switch plan {
|
||||
case .lite:
|
||||
return NativePaywallPriceInfo(value: product.localizedPriceString, suffix: "/month")
|
||||
case .pro:
|
||||
return NativePaywallPriceInfo(value: product.localizedPriceString, suffix: "/year")
|
||||
case .ai:
|
||||
return NativePaywallPriceInfo(
|
||||
value: monthlyEquivalentPrice(for: product) ?? product.localizedPriceString,
|
||||
suffix: "/mo, billed annually"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func monthlyEquivalentPrice(for product: StoreProduct) -> String? {
|
||||
guard let subscriptionPeriod = product.subscriptionPeriod else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let months: Int
|
||||
switch subscriptionPeriod.unit {
|
||||
case .month:
|
||||
months = max(subscriptionPeriod.value, 1)
|
||||
case .year:
|
||||
months = max(subscriptionPeriod.value * 12, 12)
|
||||
case .week, .day:
|
||||
return nil
|
||||
@unknown default:
|
||||
return nil
|
||||
}
|
||||
|
||||
var monthlyPrice = product.price / Decimal(months)
|
||||
var rounded = Decimal()
|
||||
NSDecimalRound(&rounded, &monthlyPrice, 2, .plain)
|
||||
|
||||
guard let formatter = product.priceFormatter?.copy() as? NumberFormatter else {
|
||||
return product.localizedPriceString
|
||||
}
|
||||
|
||||
return formatter.string(from: rounded as NSDecimalNumber)
|
||||
}
|
||||
|
||||
func bridgeError(_ message: String) -> NSError {
|
||||
NSError(
|
||||
domain: "NativePaywallBridge",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: NSLocalizedString(message, comment: "")]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
@State private var presentAnimation = false
|
||||
@State private var showCloseButton = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
CategorySelectionView(
|
||||
selectedTab: viewModel.category,
|
||||
onSelect: viewModel.select(category:)
|
||||
)
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.dismiss()
|
||||
} label: {
|
||||
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()
|
||||
.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.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()
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var content: some View {
|
||||
switch viewModel.category {
|
||||
case .pro:
|
||||
SKUnitProDetailView(viewModel: viewModel)
|
||||
case .ai:
|
||||
SKUnitIntelligentDetailView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
struct PreviewWrapper: View {
|
||||
@StateObject var viewModel = ViewModel()
|
||||
var body: some View {
|
||||
AffinePaywallPageView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
return PreviewWrapper()
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//
|
||||
// Paywall.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import RevenueCat
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
public enum Paywall {
|
||||
package static let revenueCatToken: String = "appl_FIzFhieVpSSmJRYJWwhVrgtnsVf"
|
||||
package static let revenueCatProxyEndpoit = URL(string: "https://iap.affine.pro/")!
|
||||
package static var isPurchasesConfigured = false
|
||||
|
||||
private static let setupExecution: Void = {
|
||||
#if DEBUG
|
||||
Purchases.logLevel = .debug
|
||||
#endif
|
||||
Purchases.proxyURL = revenueCatProxyEndpoit
|
||||
return ()
|
||||
}()
|
||||
|
||||
nonisolated
|
||||
public static func setup() {
|
||||
_ = setupExecution
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public static func presentWall(
|
||||
toController controller: UIViewController,
|
||||
bindWebContext context: WKWebView?,
|
||||
type: String
|
||||
) {
|
||||
let viewModel = ViewModel()
|
||||
if let context { viewModel.bind(context: context) }
|
||||
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
|
||||
controller.present(hostingController, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 806 B |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_CHECK.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 697 B |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_PEN.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 172 KiB |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 135 KiB |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 132 KiB |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 633 B |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_TEXT.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 238 KiB |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Image.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "BELIVER_ICON.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 227 KiB |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,7 @@ private extension ChatManager {
|
||||
timeoutInterval: 10
|
||||
)
|
||||
request.setValue("close", forHTTPHeaderField: "Connection")
|
||||
request = QLService.shared.authorized(request)
|
||||
|
||||
let closable = ClosableTask(detachedTask: .detached(operation: {
|
||||
let eventSource = EventSource()
|
||||
|
||||
@@ -70,7 +70,10 @@ public class IntelligentContext {
|
||||
|
||||
private init() {}
|
||||
|
||||
public func preparePresent(_ completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
public func preparePresent(
|
||||
createSession shouldCreateSession: Bool = true,
|
||||
_ completion: @escaping (Result<Void, Error>) -> Void
|
||||
) {
|
||||
assert(webView != nil)
|
||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||
prepareTemporaryDirectory()
|
||||
@@ -130,6 +133,10 @@ public class IntelligentContext {
|
||||
let currentDocumentId: String? = webViewMetadata[.currentDocId] as? String
|
||||
|
||||
dumpMetadataContents()
|
||||
guard shouldCreateSession else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
createSession(
|
||||
workspaceId: workspaceId,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Apollo
|
||||
import AffineGraphQL
|
||||
import Foundation
|
||||
|
||||
extension QLService {
|
||||
@@ -17,5 +18,36 @@ extension QLService {
|
||||
self.session.configuration.httpCookieStorage?.setCookie(cookie)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
override func sendRequest(
|
||||
_ request: URLRequest,
|
||||
taskDescription: String?,
|
||||
rawTaskCompletionHandler: RawCompletion?,
|
||||
completion: @escaping Completion
|
||||
) -> URLSessionTask {
|
||||
super.sendRequest(
|
||||
QLService.shared.authorized(request),
|
||||
taskDescription: taskDescription,
|
||||
rawTaskCompletionHandler: rawTaskCompletionHandler,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension QLService {
|
||||
func authorized(_ request: URLRequest) -> URLRequest {
|
||||
guard request.value(forHTTPHeaderField: "Authorization") == nil,
|
||||
let requestURL = request.url,
|
||||
let token = AuthAccessTokenCache.shared.token(
|
||||
for: requestURL, matching: serverBaseURL)
|
||||
else {
|
||||
return request
|
||||
}
|
||||
|
||||
var authorized = request
|
||||
authorized.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
return authorized
|
||||
}
|
||||
}
|
||||
|
||||