mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-18 14:56:59 +08:00
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:
@@ -16,6 +16,7 @@
|
||||
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507513692D1924C600AD60C0 /* RootViewController.swift */; };
|
||||
50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; };
|
||||
50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */; };
|
||||
50A485DE2E840A8000F220CE /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50A485DD2E840A8000F220CE /* StoreKit.framework */; };
|
||||
50FF428A2D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */; };
|
||||
50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */; };
|
||||
9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */; };
|
||||
@@ -57,6 +58,8 @@
|
||||
50802D5E2D112F7D00694021 /* Intelligents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Intelligents; sourceTree = "<group>"; };
|
||||
50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
|
||||
50A285D62D112A5E000D5A6D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
50A485DD2E840A8000F220CE /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
|
||||
50BBC54E2E840DBC0067C5E2 /* Products.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Products.storekit; sourceTree = "<group>"; };
|
||||
50CECF1E2E7C1084004487AA /* AffineResources */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineResources; sourceTree = "<group>"; };
|
||||
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBridgedWindowScript.swift; sourceTree = "<group>"; };
|
||||
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AffineViewController+AIButton.swift"; sourceTree = "<group>"; };
|
||||
@@ -96,6 +99,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5027D47A2E7C5FC100ADD25A /* AffineResources in Frameworks */,
|
||||
50A485DE2E840A8000F220CE /* StoreKit.framework in Frameworks */,
|
||||
5027D4782E7C5FBD00ADD25A /* AffinePaywall in Frameworks */,
|
||||
9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */,
|
||||
5027D47C2E7C5FC400ADD25A /* AffineGraphQL in Frameworks */,
|
||||
@@ -110,6 +114,7 @@
|
||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50A485DD2E840A8000F220CE /* StoreKit.framework */,
|
||||
C4C97C6B2D03027900BC2AD1 /* libaffine_mobile_native.a */,
|
||||
9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */,
|
||||
BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */,
|
||||
@@ -174,6 +179,7 @@
|
||||
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */,
|
||||
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */,
|
||||
5027D47F2E7C611900ADD25A /* Tools.swift */,
|
||||
50BBC54E2E840DBC0067C5E2 /* Products.storekit */,
|
||||
9D90BE1D2CCB9876006677DB /* Assets.xcassets */,
|
||||
9D90BE1E2CCB9876006677DB /* capacitor.config.json */,
|
||||
9D90BE1F2CCB9876006677DB /* config.xml */,
|
||||
@@ -232,7 +238,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 0920;
|
||||
LastUpgradeCheck = 2600;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
@@ -371,6 +377,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
@@ -381,6 +388,7 @@
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
@@ -388,8 +396,10 @@
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
@@ -422,6 +432,7 @@
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -432,6 +443,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
@@ -442,6 +454,7 @@
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
@@ -449,8 +462,10 @@
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
@@ -476,6 +491,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
@@ -491,7 +507,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
DEVELOPMENT_TEAM = 964G86XT2P;
|
||||
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
|
||||
@@ -527,7 +543,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
DEVELOPMENT_TEAM = 964G86XT2P;
|
||||
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
@@ -50,6 +50,9 @@
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../App/Products.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -74,4 +74,29 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
super.viewDidDisappear(animated)
|
||||
intelligentsButtonTimer?.invalidate()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
||||
if motion == .motionShake {
|
||||
showDebugMenu()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
import AffinePaywall
|
||||
extension AFFiNEViewController {
|
||||
@objc private func showDebugMenu() {
|
||||
let alert = UIAlertController(title: "Debug Menu", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Show Paywall - Pro", style: .default) { _ in
|
||||
Paywall.presentWall(toController: self, type: "Pro")
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Show Paywall - AI", style: .default) { _ in
|
||||
Paywall.presentWall(toController: self, type: "AI")
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
144
packages/frontend/apps/ios/App/App/Products.storekit
Normal file
144
packages/frontend/apps/ios/App/App/Products.storekit
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"appPolicies" : {
|
||||
"eula" : "",
|
||||
"policies" : [
|
||||
{
|
||||
"locale" : "en_US",
|
||||
"policyText" : "",
|
||||
"policyURL" : ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"identifier" : "B8962ABD",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "6736937980",
|
||||
"_askToBuyEnabled" : false,
|
||||
"_billingGracePeriodEnabled" : false,
|
||||
"_billingIssuesEnabled" : false,
|
||||
"_developerTeamID" : "73YMMDVT2M",
|
||||
"_disableDialogs" : false,
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 780406221.25107503,
|
||||
"_locale" : "en_US",
|
||||
"_renewalBillingIssuesEnabled" : false,
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
|
||||
],
|
||||
"_timeRate" : 0
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "21781265",
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"name" : "AI",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "106.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "6752564901",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "AFFiNE AI Yearly Subscription",
|
||||
"displayName" : "AFFiNE AI Yearly",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "app.affine.pro.ai.Annual",
|
||||
"recurringSubscriptionPeriod" : "P1Y",
|
||||
"referenceName" : "AI Yearly Subscription",
|
||||
"subscriptionGroupID" : "21781265",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id" : "21781263",
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"name" : "Pro",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "7.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "6752564392",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "AFFiNE Pro Monthly Subscription",
|
||||
"displayName" : "AFFiNE Pro Monthly",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "app.affine.pro.Monthly",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "Pro Monthly Subscription",
|
||||
"subscriptionGroupID" : "21781263",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "81.0",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 2,
|
||||
"internalID" : "6752564335",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "AFFiNE Pro Yearly Subscription",
|
||||
"displayName" : "AFFiNE Pro Yearly",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "app.affine.pro.Annual",
|
||||
"recurringSubscriptionPeriod" : "P1Y",
|
||||
"referenceName" : "Pro Yearly Subscription",
|
||||
"subscriptionGroupID" : "21781263",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -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