fix(ios): complete iap user interface (#13639)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - In-app purchases fully integrated for Pro and AI plans with restore, live product loading, and StoreKit test configuration. - Improvements - Refreshed paywall: intro animation, delayed close button, smoother horizontal paging, page dots interaction, per-item reveal animations, and purchase-state UI (disabled/checked when owned). - Changes - "Believer" plan and related screens removed; Pro simplified to Monthly and Annual offerings. - Chores - iOS project and build settings updated for newer toolchain and StoreKit support. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// OffsetObservingScrollView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/23/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct OffsetObservingScrollView<Content: View>: View {
|
||||
var axes: Axis.Set = [.vertical]
|
||||
var showsIndicators = true
|
||||
@Binding var offset: CGPoint
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
private let coordinateSpaceName = UUID()
|
||||
|
||||
var body: some View {
|
||||
ScrollView(axes, showsIndicators: showsIndicators) {
|
||||
PositionObservingView(
|
||||
coordinateSpace: .named(coordinateSpaceName),
|
||||
position: Binding(
|
||||
get: { offset },
|
||||
set: { newOffset in
|
||||
offset = CGPoint(
|
||||
x: -newOffset.x,
|
||||
y: -newOffset.y
|
||||
)
|
||||
}
|
||||
),
|
||||
content: content
|
||||
)
|
||||
}
|
||||
.coordinateSpace(name: coordinateSpaceName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// PositionObservingView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/23/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PositionObservingView<Content: View>: View {
|
||||
var coordinateSpace: CoordinateSpace
|
||||
@Binding var position: CGPoint
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.background(GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: PreferenceKey.self,
|
||||
value: geometry.frame(in: coordinateSpace).origin
|
||||
)
|
||||
})
|
||||
.onPreferenceChange(PreferenceKey.self) { position in
|
||||
self.position = position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PositionObservingView {
|
||||
struct PreferenceKey: SwiftUI.PreferenceKey {
|
||||
static var defaultValue: CGPoint { .zero }
|
||||
|
||||
static func reduce(value _: inout CGPoint, nextValue _: () -> CGPoint) {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,14 @@ import SwiftUI
|
||||
|
||||
struct SKUnitIntelligentDetailView: View {
|
||||
@StateObject var viewModel: ViewModel
|
||||
|
||||
@State var detailIndexInSwitching: UUID? = nil
|
||||
@State var detailIndex: Int = 0 {
|
||||
didSet { lastInteractionDate = Date() }
|
||||
}
|
||||
|
||||
@State var lastInteractionDate: Date = .init()
|
||||
@State var scrollOffset: CGPoint = .zero
|
||||
|
||||
let timer = Timer
|
||||
.publish(every: 5, on: .main, in: .common)
|
||||
@@ -28,15 +31,11 @@ struct SKUnitIntelligentDetailView: View {
|
||||
let height = r.size.height
|
||||
let width = r.size.width
|
||||
ScrollViewReader { scrollView in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
GeometryReader { geometry in
|
||||
Color.clear
|
||||
.preference(
|
||||
key: ViewOffsetKey.self,
|
||||
value: geometry.frame(in: .named("scrollView")).origin
|
||||
)
|
||||
}
|
||||
.frame(width: 0, height: 0)
|
||||
OffsetObservingScrollView(
|
||||
axes: .horizontal,
|
||||
showsIndicators: false,
|
||||
offset: $scrollOffset
|
||||
) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(0 ..< Self.features.count, id: \.self) { featureIndex in
|
||||
let feature = Self.features[featureIndex]
|
||||
@@ -47,13 +46,6 @@ struct SKUnitIntelligentDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: "scrollView")
|
||||
.onPreferenceChange(ViewOffsetKey.self) { newValue in
|
||||
let page = Int(round(-newValue.x / width))
|
||||
guard page != detailIndex else { return }
|
||||
guard page >= 0, page < Self.features.count else { return }
|
||||
detailIndex = page
|
||||
}
|
||||
.frame(height: height)
|
||||
.onChange(of: detailIndex) { newValue in
|
||||
withAnimation(.spring) {
|
||||
@@ -61,6 +53,15 @@ struct SKUnitIntelligentDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: scrollOffset) { _ in
|
||||
guard detailIndexInSwitching == nil else { return }
|
||||
guard width > 0 else { return }
|
||||
let offset = scrollOffset.x
|
||||
let newIndex = Int((offset + width / 2) / width)
|
||||
if newIndex != detailIndex,
|
||||
(0 ..< Self.features.count).contains(newIndex)
|
||||
{ detailIndex = newIndex }
|
||||
}
|
||||
}
|
||||
|
||||
PageDotsView(
|
||||
@@ -68,6 +69,12 @@ struct SKUnitIntelligentDetailView: View {
|
||||
total: Self.features.count
|
||||
) { index in
|
||||
detailIndex = index
|
||||
let token = UUID()
|
||||
detailIndexInSwitching = token
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
guard detailIndexInSwitching == token else { return }
|
||||
detailIndexInSwitching = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(timer) { _ in
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
//
|
||||
// SKUnitBelieverDetailView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SKUnitBelieverDetailView: View {
|
||||
@StateObject var viewModel: ViewModel
|
||||
|
||||
let features: [Feature] = [
|
||||
.init("Everything in AFFiNE Pro"),
|
||||
.init("Life-time Personal usage"),
|
||||
.init("1TB Cloud Storage"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
HeadlineView(viewModel: viewModel)
|
||||
Image("BELIVER_ICON", bundle: .module)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
ForEach(features.indices, id: \.self) { index in
|
||||
let feature = features[index]
|
||||
ProFeatureRowView(feature: feature, index: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SKUnitBelieverDetailView(viewModel: .vmPreviewForBeliever)
|
||||
.padding()
|
||||
}
|
||||
@@ -61,7 +61,6 @@ struct CategorySelectionView: View {
|
||||
return VStack(alignment: .leading, spacing: 12) {
|
||||
CategorySelectionView(selectedTab: .pro, onSelect: { _ in })
|
||||
CategorySelectionView(selectedTab: .ai, onSelect: { _ in })
|
||||
CategorySelectionView(selectedTab: .believer, onSelect: { _ in })
|
||||
Divider()
|
||||
PreviewWrapper()
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ struct HeadlineView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text(viewModel.selectedUnit.primaryText)
|
||||
.font(.system(size: 24, weight: .heavy))
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.contentTransition(.numericText())
|
||||
.animation(.spring.speed(2), value: viewModel.category)
|
||||
.padding(.top, 8)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// PricingOptionView.swift
|
||||
// PackageOptionView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
@@ -8,7 +8,7 @@
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct PricingOptionView: View {
|
||||
struct PackageOptionView: View {
|
||||
let price: String
|
||||
let description: String
|
||||
var badge: String
|
||||
@@ -102,12 +102,12 @@ struct PricingOptionView: View {
|
||||
#Preview {
|
||||
VStack(spacing: 16) {
|
||||
HStack(spacing: 16) {
|
||||
PricingOptionView(
|
||||
PackageOptionView(
|
||||
price: "$7.99",
|
||||
description: "Monthly",
|
||||
isSelected: false
|
||||
) {}
|
||||
PricingOptionView(
|
||||
PackageOptionView(
|
||||
price: "$6.75",
|
||||
description: "Annually",
|
||||
badge: "Save 15%",
|
||||
@@ -115,13 +115,13 @@ struct PricingOptionView: View {
|
||||
) {}
|
||||
}
|
||||
HStack(spacing: 16) {
|
||||
PricingOptionView(
|
||||
PackageOptionView(
|
||||
price: "$114514",
|
||||
description: "Monthly",
|
||||
badge: "Most Popular",
|
||||
isSelected: true
|
||||
) {}
|
||||
PricingOptionView(
|
||||
PackageOptionView(
|
||||
price: "$6.75",
|
||||
description: "Annually",
|
||||
badge: "Save 15%",
|
||||
@@ -11,36 +11,67 @@ import SwiftUI
|
||||
struct PurchaseFooterView: View {
|
||||
@StateObject var viewModel: ViewModel
|
||||
|
||||
var isPurchased: Bool {
|
||||
let package = viewModel.selectePackageOption
|
||||
return viewModel.purchasedItems.contains(package.productIdentifier)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
if viewModel.availablePricingOptions.count > 1 {
|
||||
if viewModel.availablePackageOptions.count > 1 {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(viewModel.availablePricingOptions) { option in
|
||||
PricingOptionView(
|
||||
ForEach(viewModel.availablePackageOptions) { option in
|
||||
PackageOptionView(
|
||||
price: option.price,
|
||||
description: option.description,
|
||||
badge: option.badge ?? "",
|
||||
isSelected: option.id == viewModel.selectedPricingIdentifier
|
||||
isSelected: option.id == viewModel.selectedPackageIdentifier
|
||||
) {
|
||||
viewModel.select(pricingOption: option)
|
||||
viewModel.select(packageOption: option)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isPurchased)
|
||||
}
|
||||
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: viewModel.selectedPricingOption.primaryTitle,
|
||||
secondaryTitle: viewModel.selectedPricingOption.secondaryTitle,
|
||||
callback: viewModel.purchase
|
||||
)
|
||||
if viewModel.updating {
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Height Placeholder",
|
||||
secondaryTitle: "",
|
||||
isPurchased: false
|
||||
) {}
|
||||
.hidden()
|
||||
.background(AffineColors.buttonPrimary.color)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: viewModel.selectePackageOption.primaryTitle,
|
||||
secondaryTitle: viewModel.selectePackageOption.secondaryTitle,
|
||||
isPurchased: isPurchased,
|
||||
callback: viewModel.purchase
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
Button(action: viewModel.restore) {
|
||||
Text("Restore Purchase")
|
||||
if isPurchased {
|
||||
Text("Already Purchased")
|
||||
} else {
|
||||
Text("Restore Purchase")
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(AffineColors.textSecondary.color)
|
||||
.opacity(viewModel.products.isEmpty ? 0 : 1)
|
||||
.disabled(isPurchased)
|
||||
}
|
||||
.animation(.spring, value: viewModel.updating)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,43 +11,55 @@ import SwiftUI
|
||||
struct TheGiveMeMoneyButtonView: View {
|
||||
let primaryTitle: String
|
||||
let secondaryTitle: String
|
||||
let isPurchased: Bool
|
||||
let callback: () -> Void
|
||||
|
||||
init(
|
||||
primaryTitle: String = "",
|
||||
secondaryTitle: String = "",
|
||||
isPurchased: Bool,
|
||||
callback: @escaping () -> Void = {}
|
||||
) {
|
||||
self.primaryTitle = primaryTitle
|
||||
self.secondaryTitle = secondaryTitle
|
||||
self.isPurchased = isPurchased
|
||||
self.callback = callback
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button { callback() } label: {
|
||||
HStack(spacing: 4) {
|
||||
if !primaryTitle.isEmpty {
|
||||
Text(primaryTitle)
|
||||
.bold()
|
||||
.font(.system(size: 16))
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
if !secondaryTitle.isEmpty {
|
||||
Text("(\(secondaryTitle))")
|
||||
.font(.system(size: 12))
|
||||
.opacity(0.8)
|
||||
.contentTransition(.numericText())
|
||||
if isPurchased {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(AffineColors.layerPureWhite.color)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.padding(12)
|
||||
} else {
|
||||
HStack(spacing: 4) {
|
||||
if !primaryTitle.isEmpty {
|
||||
Text(primaryTitle)
|
||||
.bold()
|
||||
.font(.system(size: 16))
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
if !secondaryTitle.isEmpty {
|
||||
Text("(\(secondaryTitle))")
|
||||
.font(.system(size: 12))
|
||||
.opacity(0.8)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
.foregroundColor(AffineColors.layerPureWhite.color)
|
||||
.padding(12)
|
||||
}
|
||||
.foregroundColor(AffineColors.layerPureWhite.color)
|
||||
.padding(12)
|
||||
}
|
||||
.animation(.spring, value: primaryTitle)
|
||||
.animation(.spring, value: secondaryTitle)
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 32)
|
||||
.background(AffineColors.buttonPrimary.color)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.disabled(isPurchased)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,19 +69,28 @@ struct TheGiveMeMoneyButtonView: View {
|
||||
VStack(spacing: 16) {
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $6.75 per month",
|
||||
secondaryTitle: ""
|
||||
secondaryTitle: "",
|
||||
isPurchased: false
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $10 per month",
|
||||
secondaryTitle: ""
|
||||
secondaryTitle: "",
|
||||
isPurchased: false
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "$8.9 per month",
|
||||
secondaryTitle: "billed annually"
|
||||
secondaryTitle: "billed annually",
|
||||
isPurchased: false
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $499",
|
||||
secondaryTitle: ""
|
||||
secondaryTitle: "",
|
||||
isPurchased: false
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $499",
|
||||
secondaryTitle: "",
|
||||
isPurchased: true
|
||||
)
|
||||
}
|
||||
.padding(32)
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
//
|
||||
// ProFeaturesCardView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct ProFeaturesCardView: View {
|
||||
let features: [Feature]
|
||||
let headerText: String
|
||||
|
||||
let timer = Timer
|
||||
.publish(every: 0.08, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
@State var animationIndex: Int64 = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if !headerText.isEmpty {
|
||||
Text(headerText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(AffineColors.textSecondary.color)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
ForEach(Array(features.enumerated()), id: \.element.id) { index, feature in
|
||||
ProFeatureRowView(feature: feature, index: index)
|
||||
.opacity(index < animationIndex ? 1 : 0)
|
||||
}
|
||||
}
|
||||
.animation(.spring.speed(2), value: animationIndex)
|
||||
.onChange(of: features) { _ in animationIndex = 0 }
|
||||
.onReceive(timer) { _ in animationIndex += 1 }
|
||||
.clipped()
|
||||
.padding(16)
|
||||
.background(AffineColors.layerBackgroundPrimary.color)
|
||||
.cornerRadius(16)
|
||||
.shadow(color: AffineColors.layerBorder.color.opacity(0.08), radius: 8, y: 2)
|
||||
.animation(.spring.speed(2), value: features)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Pro") {
|
||||
ProFeaturesCardView(features: SKUnitSubcategoryProPlan.default.features, headerText: SKUnitSubcategoryProPlan.default.headerText)
|
||||
.padding()
|
||||
.background(Color.gray.ignoresSafeArea())
|
||||
}
|
||||
|
||||
#Preview("Pro team") {
|
||||
ProFeaturesCardView(
|
||||
features: SKUnitSubcategoryProPlan.team.features,
|
||||
headerText: SKUnitSubcategoryProPlan.team.headerText
|
||||
)
|
||||
.padding()
|
||||
.background(Color.gray.ignoresSafeArea())
|
||||
}
|
||||
|
||||
#Preview("Self Hosted") {
|
||||
ProFeaturesCardView(features: SKUnitSubcategoryProPlan.selfHost.features, headerText: SKUnitSubcategoryProPlan.selfHost.headerText)
|
||||
.padding()
|
||||
.background(Color.gray.ignoresSafeArea())
|
||||
}
|
||||
@@ -11,35 +11,82 @@ import SwiftUI
|
||||
struct SKUnitProDetailView: View {
|
||||
@StateObject var viewModel: ViewModel
|
||||
|
||||
@State var selection: SKUnitSubcategoryProPlan = .default
|
||||
@State var selection: SKUnitSubcategoryProPlan
|
||||
@State var headerText: String
|
||||
@State var features: [Feature]
|
||||
@State var animationIndex: Int64 = 0
|
||||
|
||||
let timer = Timer
|
||||
.publish(every: 0.075, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
|
||||
init(viewModel: ViewModel) {
|
||||
_viewModel = .init(wrappedValue: viewModel)
|
||||
let item = SKUnitSubcategoryProPlan.default
|
||||
_selection = .init(initialValue: item)
|
||||
_headerText = .init(initialValue: item.headerText)
|
||||
_features = .init(initialValue: item.features)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Picker("Plan", selection: $selection) {
|
||||
ForEach(SKUnitSubcategoryProPlan.allCases) { plan in
|
||||
Text(plan.title).tag(plan)
|
||||
if SKUnitSubcategoryProPlan.allCases.count > 1 {
|
||||
Picker("Plan", selection: $selection) {
|
||||
ForEach(SKUnitSubcategoryProPlan.allCases) { plan in
|
||||
Text(plan.title).tag(plan)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: selection) { _ in
|
||||
viewModel.select(subcategory: selection)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: selection) { _ in
|
||||
viewModel.select(subcategory: selection)
|
||||
}
|
||||
|
||||
HeadlineView(viewModel: viewModel)
|
||||
|
||||
ScrollView {
|
||||
ProFeaturesCardView(
|
||||
features: selection.features,
|
||||
headerText: selection.headerText
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if !headerText.isEmpty {
|
||||
Text(headerText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(AffineColors.textSecondary.color)
|
||||
.contentTransition(.numericText())
|
||||
.transition(.opacity)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
ForEach(Array(features.enumerated()), id: \.element.id) { index, feature in
|
||||
ProFeatureRowView(feature: feature, index: index)
|
||||
.opacity(index < animationIndex ? 1 : 0)
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
.padding(16)
|
||||
.background(AffineColors.layerBackgroundPrimary.color)
|
||||
.cornerRadius(16)
|
||||
.shadow(color: AffineColors.layerBorder.color.opacity(0.08), radius: 8, y: 2)
|
||||
.padding(16)
|
||||
}
|
||||
.padding(-16)
|
||||
.animation(.spring.speed(2), value: animationIndex)
|
||||
.onReceive(timer) { _ in animationIndex += 1 }
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: .top
|
||||
)
|
||||
.onChange(of: selection) { _ in updateSelectionContents() }
|
||||
}
|
||||
}
|
||||
|
||||
func updateSelectionContents() {
|
||||
let headerText = selection.headerText
|
||||
if self.headerText != headerText {
|
||||
self.headerText = headerText
|
||||
}
|
||||
let features = selection.features
|
||||
if self.features != features {
|
||||
self.features = features
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Feature: Identifiable, Equatable {
|
||||
struct Feature: Identifiable, Equatable, Hashable {
|
||||
var id = UUID()
|
||||
var text: String
|
||||
var isHighlighted: Bool // For text like "Everything in AFFINE Pro"
|
||||
|
||||
@@ -13,13 +13,15 @@ extension SKUnit {
|
||||
category: SKUnitCategory.ai,
|
||||
primaryText: "AFFINE AI",
|
||||
secondaryText: "A true multimodal AI copilot.",
|
||||
pricing: [
|
||||
SKUnitPricingOption(
|
||||
package: [
|
||||
SKUnitPackageOption(
|
||||
price: "$8.9 per month",
|
||||
description: "",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "$8.9 per month",
|
||||
secondaryTitle: "billed annually"
|
||||
secondaryTitle: "billed annually",
|
||||
productIdentifier: "app.affine.pro.ai.Annual",
|
||||
revenueCatIdentifier: "app.affine.pro.ai.Annual"
|
||||
),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
//
|
||||
// SKUnit+Believer.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension SKUnit {
|
||||
static let believerUnits: [SKUnit] = [
|
||||
SKUnit(
|
||||
category: SKUnitCategory.believer,
|
||||
primaryText: "Believer Plan",
|
||||
secondaryText: "AFFINE's Everything",
|
||||
pricing: [
|
||||
SKUnitPricingOption(
|
||||
price: "$499",
|
||||
description: "",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "Upgrade for $499",
|
||||
secondaryTitle: ""
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -14,67 +14,25 @@ extension SKUnit {
|
||||
subcategory: SKUnitSubcategoryProPlan.default,
|
||||
primaryText: "Pro",
|
||||
secondaryText: "For family and small teams.",
|
||||
pricing: [
|
||||
SKUnitPricingOption(
|
||||
package: [
|
||||
SKUnitPackageOption(
|
||||
price: "$7.99",
|
||||
description: "Monthly",
|
||||
isDefaultSelected: false,
|
||||
primaryTitle: "Upgrade for $7.99/month",
|
||||
secondaryTitle: ""
|
||||
secondaryTitle: "",
|
||||
productIdentifier: "app.affine.pro.Monthly",
|
||||
revenueCatIdentifier: "app.affine.pro.Monthly"
|
||||
),
|
||||
SKUnitPricingOption(
|
||||
SKUnitPackageOption(
|
||||
price: "$6.75",
|
||||
description: "Annual",
|
||||
badge: "Save 15%",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "Upgrade for $6.75/month",
|
||||
secondaryTitle: ""
|
||||
),
|
||||
]
|
||||
),
|
||||
SKUnit(
|
||||
category: SKUnitCategory.pro,
|
||||
subcategory: SKUnitSubcategoryProPlan.team,
|
||||
primaryText: "Pro team",
|
||||
secondaryText: "Best for scalable teams.",
|
||||
pricing: [
|
||||
SKUnitPricingOption(
|
||||
price: "$12",
|
||||
description: "Per seat monthly",
|
||||
isDefaultSelected: false,
|
||||
primaryTitle: "Upgrade for $12/month",
|
||||
secondaryTitle: ""
|
||||
),
|
||||
SKUnitPricingOption(
|
||||
price: "$10",
|
||||
description: "Annual",
|
||||
badge: "Save 15%",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "Upgrade for $10/month",
|
||||
secondaryTitle: ""
|
||||
),
|
||||
]
|
||||
),
|
||||
SKUnit(
|
||||
category: SKUnitCategory.pro,
|
||||
subcategory: SKUnitSubcategoryProPlan.selfHost,
|
||||
primaryText: "Self Hosted team",
|
||||
secondaryText: "Best for scalable teams.",
|
||||
pricing: [
|
||||
SKUnitPricingOption(
|
||||
price: "$12",
|
||||
description: "Per seat monthly",
|
||||
isDefaultSelected: false,
|
||||
primaryTitle: "Upgrade for $12/month",
|
||||
secondaryTitle: ""
|
||||
),
|
||||
SKUnitPricingOption(
|
||||
price: "$10",
|
||||
description: "Annual",
|
||||
badge: "Save 15%",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "Upgrade for $10/month",
|
||||
secondaryTitle: ""
|
||||
secondaryTitle: "",
|
||||
productIdentifier: "app.affine.pro.Annual",
|
||||
revenueCatIdentifier: "app.affine.pro.Annual"
|
||||
),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -13,20 +13,20 @@ struct SKUnit: Identifiable, Sendable {
|
||||
let subcategory: any SKUnitSubcategorizable
|
||||
let primaryText: String
|
||||
let secondaryText: String
|
||||
let pricing: [SKUnitPricingOption]
|
||||
let package: [SKUnitPackageOption]
|
||||
|
||||
init(
|
||||
category: SKUnitCategory,
|
||||
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single,
|
||||
primaryText: String,
|
||||
secondaryText: String,
|
||||
pricing: [SKUnitPricingOption]
|
||||
package: [SKUnitPackageOption]
|
||||
) {
|
||||
self.category = category
|
||||
self.subcategory = subcategory
|
||||
self.primaryText = primaryText
|
||||
self.secondaryText = secondaryText
|
||||
self.pricing = pricing
|
||||
self.package = package
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ extension SKUnit {
|
||||
static let allUnits: [SKUnit] = [
|
||||
proUnits,
|
||||
aiUnits,
|
||||
believerUnits,
|
||||
].flatMap(\.self)
|
||||
|
||||
static func units(for category: SKUnitCategory) -> [SKUnit] {
|
||||
|
||||
@@ -12,7 +12,6 @@ enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
|
||||
|
||||
case pro
|
||||
case ai
|
||||
case believer
|
||||
}
|
||||
|
||||
extension SKUnitCategory {
|
||||
@@ -20,7 +19,6 @@ extension SKUnitCategory {
|
||||
switch self {
|
||||
case .pro: "AFFINE.Pro"
|
||||
case .ai: "AI"
|
||||
case .believer: "Believer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// SKUnitPricingOption.swift
|
||||
// SKUnitPackageOption.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
@@ -7,10 +7,10 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SKUnitPricingOption: Identifiable, Equatable {
|
||||
struct SKUnitPackageOption: Identifiable, Equatable {
|
||||
var id: UUID
|
||||
|
||||
// pricing selection button
|
||||
// package selection button
|
||||
var price: String
|
||||
var description: String
|
||||
var badge: String?
|
||||
@@ -20,6 +20,10 @@ struct SKUnitPricingOption: Identifiable, Equatable {
|
||||
var primaryTitle: String
|
||||
var secondaryTitle: String
|
||||
|
||||
// product identifiers
|
||||
var productIdentifier: String
|
||||
var revenueCatIdentifier: String
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
price: String,
|
||||
@@ -27,7 +31,9 @@ struct SKUnitPricingOption: Identifiable, Equatable {
|
||||
badge: String? = nil,
|
||||
isDefaultSelected: Bool = false,
|
||||
primaryTitle: String,
|
||||
secondaryTitle: String
|
||||
secondaryTitle: String,
|
||||
productIdentifier: String,
|
||||
revenueCatIdentifier: String
|
||||
) {
|
||||
self.id = id
|
||||
self.price = price
|
||||
@@ -36,5 +42,7 @@ struct SKUnitPricingOption: Identifiable, Equatable {
|
||||
self.isDefaultSelected = isDefaultSelected
|
||||
self.primaryTitle = primaryTitle
|
||||
self.secondaryTitle = secondaryTitle
|
||||
self.productIdentifier = productIdentifier
|
||||
self.revenueCatIdentifier = revenueCatIdentifier
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,10 @@ import Foundation
|
||||
|
||||
enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable {
|
||||
case `default`
|
||||
case team
|
||||
case selfHost
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .default: "Pro"
|
||||
case .team: "Pro team"
|
||||
case .selfHost: "Self Hosted"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +20,6 @@ enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable {
|
||||
switch self {
|
||||
case .default:
|
||||
"For family and small teams."
|
||||
case .team:
|
||||
"Best for scalable teams."
|
||||
case .selfHost:
|
||||
"Best for scalable teams."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,10 +29,6 @@ extension SKUnitSubcategoryProPlan {
|
||||
switch self {
|
||||
case .default:
|
||||
"Include in Pro"
|
||||
case .team:
|
||||
"Include in Team Workspace"
|
||||
case .selfHost:
|
||||
"Both in Teams & Enterprise"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,24 +44,6 @@ extension SKUnitSubcategoryProPlan {
|
||||
Feature("Community Support"),
|
||||
Feature("Real-time Syncing & Collaboration for more people"),
|
||||
]
|
||||
case .team:
|
||||
[
|
||||
Feature("Everything in AFFINE Pro", isHighlighted: true),
|
||||
Feature("100 GB initial storage + 20 GB per seat"),
|
||||
Feature("500 MB of maximum file size"),
|
||||
Feature("Unlimited team members (10+ seats)"),
|
||||
Feature("Multiple admin roles"),
|
||||
Feature("Priority customer support"),
|
||||
]
|
||||
case .selfHost:
|
||||
[
|
||||
Feature("Everything in Self Hosted FOSS"),
|
||||
Feature("100 GB initial storage + 20 GB per seat"),
|
||||
Feature("500 MB of maximum file size"),
|
||||
Feature("Unlimited team members (10+ seats)"),
|
||||
Feature("Multiple admin roles"),
|
||||
Feature("Priority customer support"),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// Store.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/24/25.
|
||||
//
|
||||
|
||||
import StoreKit
|
||||
|
||||
let store = Store.shared
|
||||
|
||||
final nonisolated class Store: ObservableObject, Sendable {
|
||||
static let shared = Store()
|
||||
|
||||
private init() {}
|
||||
|
||||
func fetchAppStoreContents() async throws {
|
||||
try await AppStore.sync()
|
||||
}
|
||||
|
||||
func fetchProducts() async throws -> [Product] {
|
||||
let identifiers = SKUnit.allUnits
|
||||
.flatMap(\.package)
|
||||
.map(\.productIdentifier)
|
||||
print("fetching products for identifiers: \(identifiers)")
|
||||
let products = try await Product.products(
|
||||
for: identifiers.map { .init($0) }
|
||||
)
|
||||
if products.count != identifiers.count {
|
||||
throw NSError(domain: "AffinePaywall", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: String(localized: "Failed to fetch all products from App Store."),
|
||||
])
|
||||
}
|
||||
return products
|
||||
}
|
||||
|
||||
func fetchEntitlements() async throws -> Set<String> {
|
||||
var purchasedItems: Set<String> = []
|
||||
|
||||
for await result in Transaction.currentEntitlements {
|
||||
if case let .verified(transaction) = result {
|
||||
guard transaction.revocationDate == nil else { continue }
|
||||
|
||||
switch transaction.productType {
|
||||
case .nonConsumable, .consumable:
|
||||
purchasedItems.insert(transaction.productID)
|
||||
case .autoRenewable, .nonRenewable:
|
||||
if let status = await transaction.subscriptionStatus,
|
||||
status.state == .subscribed
|
||||
{ purchasedItems.insert(transaction.productID) }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return purchasedItems
|
||||
}
|
||||
}
|
||||
@@ -6,26 +6,122 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension ViewModel {
|
||||
func purchase() {
|
||||
let unit = selectedUnit
|
||||
let option = selectedPricingOption
|
||||
let option = selectePackageOption
|
||||
|
||||
assert(!updating)
|
||||
guard !updating else { return }
|
||||
print(#function, unit, option)
|
||||
|
||||
Task.detached {
|
||||
await MainActor.run { self.updating = true }
|
||||
var shouldDismiss = false
|
||||
|
||||
let product = await self.products.first {
|
||||
$0.id == option.productIdentifier
|
||||
}
|
||||
|
||||
if let product {
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .pending:
|
||||
break
|
||||
case let .success(transaction):
|
||||
print("purchase success", transaction)
|
||||
shouldDismiss = true
|
||||
case .userCancelled:
|
||||
break
|
||||
@unknown default:
|
||||
assertionFailure()
|
||||
}
|
||||
} else { assertionFailure() } // should never happen
|
||||
|
||||
await MainActor.run {
|
||||
self.updating = false
|
||||
if shouldDismiss { self.dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restore() {
|
||||
let unit = selectedUnit
|
||||
let option = selectedPricingOption
|
||||
let option = selectePackageOption
|
||||
|
||||
assert(!updating)
|
||||
guard !updating else { return }
|
||||
print(#function, unit, option)
|
||||
|
||||
updateAppStoreStatus(initial: false)
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
let unit = selectedUnit
|
||||
let option = selectedPricingOption
|
||||
|
||||
print(#function, unit, option)
|
||||
print(#function)
|
||||
associatedController?.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated extension ViewModel {
|
||||
func updateAppStoreStatusExecute(initial: Bool) async {
|
||||
guard await !updating else { return }
|
||||
guard let controller = await associatedController else { return }
|
||||
await MainActor.run { self.updating = true }
|
||||
|
||||
do {
|
||||
// before we continue, sync any changes from App Store
|
||||
// this will ask user to sign in if needed
|
||||
do {
|
||||
try await store.fetchAppStoreContents()
|
||||
} catch {
|
||||
// ignore user's cancellation on restore, not a huge deal
|
||||
print("updateAppStoreItems error:", error)
|
||||
}
|
||||
|
||||
// now we fetch records from app store
|
||||
let products = try await store.fetchProducts()
|
||||
await MainActor.run { self.products = products }
|
||||
|
||||
// fetch purchased items if signed in
|
||||
do {
|
||||
let purchase = try await store.fetchEntitlements()
|
||||
await MainActor.run { self.purchasedItems = purchase }
|
||||
} catch {
|
||||
print("fetchEntitlements error:", error)
|
||||
if !initial { throw error }
|
||||
}
|
||||
|
||||
// select the package under purchased items if any
|
||||
let availablePackages = await availablePackageOptions
|
||||
let purchase = await purchasedItems
|
||||
let purchasedPackages = availablePackages.filter {
|
||||
purchase.contains($0.productIdentifier)
|
||||
}
|
||||
assert(purchasedPackages.count <= 1)
|
||||
if let firstPurchased = purchasedPackages.first {
|
||||
await MainActor.run {
|
||||
self.select(packageOption: firstPurchased)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
let alert = UIAlertController(
|
||||
title: String(localized: "Error"),
|
||||
message: error.localizedDescription,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(
|
||||
UIAlertAction(
|
||||
title: String(localized: "OK"),
|
||||
style: .default
|
||||
) { [self] _ in dismiss() }
|
||||
)
|
||||
controller.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run { self.updating = false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,4 @@ extension ViewModel {
|
||||
vm.select(category: .ai)
|
||||
return vm
|
||||
}()
|
||||
|
||||
static let vmPreviewForBeliever: ViewModel = {
|
||||
let vm = ViewModel()
|
||||
vm.select(category: .believer)
|
||||
return vm
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@@ -15,12 +16,30 @@ class ViewModel: ObservableObject {
|
||||
|
||||
@Published private(set) var category: SKUnitCategory = .pro
|
||||
@Published private(set) var subcategory: any SKUnitSubcategorizable = SKUnitSubcategoryProPlan.default
|
||||
@Published private(set) var selectedPricingIdentifier: UUID = SKUnit.unit(
|
||||
@Published private(set) var selectedPackageIdentifier: UUID = SKUnit.unit(
|
||||
for: .pro,
|
||||
subcategory: SKUnitSubcategoryProPlan.default
|
||||
)!.pricing.first { $0.isDefaultSelected }!.id
|
||||
)!.package.first { $0.isDefaultSelected }!.id
|
||||
|
||||
init() {}
|
||||
@Published var updating = false
|
||||
@Published var products: [Product] = []
|
||||
@Published var purchasedItems: Set<String> = []
|
||||
|
||||
private(set) weak var associatedController: UIViewController?
|
||||
|
||||
init() {
|
||||
updateAppStoreStatus(initial: true)
|
||||
}
|
||||
|
||||
func updateAppStoreStatus(initial: Bool) {
|
||||
Task.detached {
|
||||
await self.updateAppStoreStatusExecute(initial: initial)
|
||||
}
|
||||
}
|
||||
|
||||
func bind(controller: UIViewController) {
|
||||
associatedController = controller
|
||||
}
|
||||
|
||||
func select(category: SKUnitCategory) {
|
||||
self.category = category
|
||||
@@ -30,7 +49,7 @@ class ViewModel: ObservableObject {
|
||||
if !subcategoryExists {
|
||||
subcategory = units.first!.subcategory
|
||||
}
|
||||
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
|
||||
_ = selectePackageOption // ensure selectePackageOption is valid
|
||||
}
|
||||
|
||||
func select(subcategory: any SKUnitSubcategorizable) {
|
||||
@@ -45,20 +64,20 @@ class ViewModel: ObservableObject {
|
||||
} else {
|
||||
self.subcategory = subcategory
|
||||
}
|
||||
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
|
||||
_ = selectePackageOption // ensure selectePackageOption is valid
|
||||
}
|
||||
|
||||
func select(pricingOption option: SKUnitPricingOption) {
|
||||
selectedPricingIdentifier = option.id
|
||||
func select(packageOption option: SKUnitPackageOption) {
|
||||
selectedPackageIdentifier = option.id
|
||||
|
||||
let unit = availableUnits
|
||||
.first { unit in
|
||||
unit.pricing.contains { $0.id == option.id }
|
||||
unit.package.contains { $0.id == option.id }
|
||||
}!
|
||||
category = unit.category
|
||||
subcategory = unit.subcategory
|
||||
|
||||
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
|
||||
_ = selectePackageOption // ensure selectePackageOption is valid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,21 +98,21 @@ extension ViewModel {
|
||||
return item
|
||||
}
|
||||
|
||||
var selectedPricingOption: SKUnitPricingOption {
|
||||
let item = selectedUnit.pricing
|
||||
.first { $0.id == selectedPricingIdentifier }
|
||||
var selectePackageOption: SKUnitPackageOption {
|
||||
let item = selectedUnit.package
|
||||
.first { $0.id == selectedPackageIdentifier }
|
||||
if let item { return item }
|
||||
let defaultItem = selectedUnit.pricing.first { $0.isDefaultSelected }
|
||||
let defaultItem = selectedUnit.package.first { $0.isDefaultSelected }
|
||||
if let defaultItem {
|
||||
selectedPricingIdentifier = defaultItem.id
|
||||
selectedPackageIdentifier = defaultItem.id
|
||||
return defaultItem
|
||||
}
|
||||
let lastItem = selectedUnit.pricing.last!
|
||||
selectedPricingIdentifier = lastItem.id
|
||||
let lastItem = selectedUnit.package.last!
|
||||
selectedPackageIdentifier = lastItem.id
|
||||
return lastItem
|
||||
}
|
||||
|
||||
var availablePricingOptions: [SKUnitPricingOption] {
|
||||
selectedUnit.pricing
|
||||
var availablePackageOptions: [SKUnitPackageOption] {
|
||||
selectedUnit.package
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ struct AffinePaywallPageView: View {
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State private var presentAnimation = false
|
||||
@State private var showCloseButton = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
@@ -24,10 +27,17 @@ struct AffinePaywallPageView: View {
|
||||
Button {
|
||||
viewModel.dismiss()
|
||||
} label: {
|
||||
Image(AffineIcons.close.rawValue)
|
||||
AffineIcons.close.image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(AffineColors.textSecondary.color)
|
||||
.opacity(showCloseButton ? 1 : 0)
|
||||
.disabled(!showCloseButton)
|
||||
.animation(.spring, value: showCloseButton)
|
||||
}
|
||||
ZStack(alignment: .topLeading) {
|
||||
Spacer()
|
||||
@@ -45,11 +55,22 @@ struct AffinePaywallPageView: View {
|
||||
.animation(.spring.speed(2), value: viewModel.category)
|
||||
|
||||
PurchaseFooterView(viewModel: viewModel)
|
||||
.animation(.spring.speed(2), value: viewModel.selectedPricingIdentifier)
|
||||
.animation(.spring.speed(2), value: viewModel.selectedPackageIdentifier)
|
||||
}
|
||||
.padding()
|
||||
.opacity(presentAnimation ? 1 : 0)
|
||||
.scaleEffect(presentAnimation ? 1 : 0.95, anchor: .top)
|
||||
.animation(.spring, value: presentAnimation)
|
||||
.onAppear {
|
||||
presentAnimation = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
showCloseButton = true
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
AffineColors.layerBackgroundSecondary.color
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,8 +81,6 @@ struct AffinePaywallPageView: View {
|
||||
SKUnitProDetailView(viewModel: viewModel)
|
||||
case .ai:
|
||||
SKUnitIntelligentDetailView(viewModel: viewModel)
|
||||
case .believer:
|
||||
SKUnitBelieverDetailView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//
|
||||
// File.swift
|
||||
// Paywall.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
public enum Paywall {
|
||||
@MainActor
|
||||
@@ -15,13 +15,19 @@ public enum Paywall {
|
||||
type: String
|
||||
) {
|
||||
let viewModel = ViewModel()
|
||||
switch type {
|
||||
// TODO: FIGURE OUT PAYWALL TYPES
|
||||
switch type.lowercased() {
|
||||
case "pro":
|
||||
viewModel.select(category: .pro)
|
||||
viewModel.select(subcategory: SKUnitSubcategoryProPlan.default)
|
||||
case "ai":
|
||||
viewModel.select(category: .ai)
|
||||
viewModel.select(subcategory: SKUnitSingleSubcategory.single)
|
||||
default:
|
||||
break
|
||||
}
|
||||
let view = AffinePaywallPageView(viewModel: viewModel)
|
||||
let hostingController = UIHostingController(rootView: view)
|
||||
viewModel.bind(controller: hostingController)
|
||||
hostingController.modalPresentationStyle = .overFullScreen
|
||||
hostingController.modalTransitionStyle = .coverVertical
|
||||
hostingController.preferredContentSize = CGSize(width: 555, height: 555) // for iPads
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
//
|
||||
// ViewOffsetKey.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ViewOffsetKey: @MainActor PreferenceKey {
|
||||
static var defaultValue: CGPoint = .zero
|
||||
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user