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 */; };
|
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507513692D1924C600AD60C0 /* RootViewController.swift */; };
|
||||||
50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; };
|
50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; };
|
||||||
50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D52D112A5E000D5A6D /* InfoPlist.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 */; };
|
50FF428A2D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */; };
|
||||||
50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.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 */; };
|
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>"; };
|
50802D5E2D112F7D00694021 /* Intelligents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Intelligents; sourceTree = "<group>"; };
|
||||||
50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; 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>"; };
|
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>"; };
|
50CECF1E2E7C1084004487AA /* AffineResources */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineResources; sourceTree = "<group>"; };
|
||||||
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBridgedWindowScript.swift; 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>"; };
|
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AffineViewController+AIButton.swift"; sourceTree = "<group>"; };
|
||||||
@@ -96,6 +99,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
5027D47A2E7C5FC100ADD25A /* AffineResources in Frameworks */,
|
5027D47A2E7C5FC100ADD25A /* AffineResources in Frameworks */,
|
||||||
|
50A485DE2E840A8000F220CE /* StoreKit.framework in Frameworks */,
|
||||||
5027D4782E7C5FBD00ADD25A /* AffinePaywall in Frameworks */,
|
5027D4782E7C5FBD00ADD25A /* AffinePaywall in Frameworks */,
|
||||||
9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */,
|
9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */,
|
||||||
5027D47C2E7C5FC400ADD25A /* AffineGraphQL in Frameworks */,
|
5027D47C2E7C5FC400ADD25A /* AffineGraphQL in Frameworks */,
|
||||||
@@ -110,6 +114,7 @@
|
|||||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
|
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
50A485DD2E840A8000F220CE /* StoreKit.framework */,
|
||||||
C4C97C6B2D03027900BC2AD1 /* libaffine_mobile_native.a */,
|
C4C97C6B2D03027900BC2AD1 /* libaffine_mobile_native.a */,
|
||||||
9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */,
|
9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */,
|
||||||
BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */,
|
BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */,
|
||||||
@@ -174,6 +179,7 @@
|
|||||||
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */,
|
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */,
|
||||||
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */,
|
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */,
|
||||||
5027D47F2E7C611900ADD25A /* Tools.swift */,
|
5027D47F2E7C611900ADD25A /* Tools.swift */,
|
||||||
|
50BBC54E2E840DBC0067C5E2 /* Products.storekit */,
|
||||||
9D90BE1D2CCB9876006677DB /* Assets.xcassets */,
|
9D90BE1D2CCB9876006677DB /* Assets.xcassets */,
|
||||||
9D90BE1E2CCB9876006677DB /* capacitor.config.json */,
|
9D90BE1E2CCB9876006677DB /* capacitor.config.json */,
|
||||||
9D90BE1F2CCB9876006677DB /* config.xml */,
|
9D90BE1F2CCB9876006677DB /* config.xml */,
|
||||||
@@ -232,7 +238,7 @@
|
|||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastSwiftUpdateCheck = 0920;
|
LastSwiftUpdateCheck = 0920;
|
||||||
LastUpgradeCheck = 0920;
|
LastUpgradeCheck = 2600;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
504EC3031FED79650016851F = {
|
504EC3031FED79650016851F = {
|
||||||
CreatedOnToolsVersion = 9.2;
|
CreatedOnToolsVersion = 9.2;
|
||||||
@@ -371,6 +377,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
@@ -381,6 +388,7 @@
|
|||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
CLANG_WARN_COMMA = YES;
|
CLANG_WARN_COMMA = YES;
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
@@ -388,8 +396,10 @@
|
|||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
CLANG_WARN_NON_LITERAL_NULL_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_LITERAL_CONVERSION = YES;
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
@@ -422,6 +432,7 @@
|
|||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
|
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -432,6 +443,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
@@ -442,6 +454,7 @@
|
|||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
CLANG_WARN_COMMA = YES;
|
CLANG_WARN_COMMA = YES;
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
@@ -449,8 +462,10 @@
|
|||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
CLANG_WARN_NON_LITERAL_NULL_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_LITERAL_CONVERSION = YES;
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
@@ -476,6 +491,7 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
|
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
@@ -491,7 +507,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 12;
|
CURRENT_PROJECT_VERSION = 12;
|
||||||
DEVELOPMENT_TEAM = 964G86XT2P;
|
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
|
||||||
@@ -527,7 +543,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 12;
|
CURRENT_PROJECT_VERSION = 12;
|
||||||
DEVELOPMENT_TEAM = 964G86XT2P;
|
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1600"
|
LastUpgradeVersion = "2600"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1600"
|
LastUpgradeVersion = "2600"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
@@ -50,6 +50,9 @@
|
|||||||
ReferencedContainer = "container:App.xcodeproj">
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<StoreKitConfigurationFileReference
|
||||||
|
identifier = "../App/Products.storekit">
|
||||||
|
</StoreKitConfigurationFileReference>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
|||||||
@@ -74,4 +74,29 @@ class AFFiNEViewController: CAPBridgeViewController {
|
|||||||
super.viewDidDisappear(animated)
|
super.viewDidDisappear(animated)
|
||||||
intelligentsButtonTimer?.invalidate()
|
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 {
|
struct SKUnitIntelligentDetailView: View {
|
||||||
@StateObject var viewModel: ViewModel
|
@StateObject var viewModel: ViewModel
|
||||||
|
|
||||||
|
@State var detailIndexInSwitching: UUID? = nil
|
||||||
@State var detailIndex: Int = 0 {
|
@State var detailIndex: Int = 0 {
|
||||||
didSet { lastInteractionDate = Date() }
|
didSet { lastInteractionDate = Date() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@State var lastInteractionDate: Date = .init()
|
@State var lastInteractionDate: Date = .init()
|
||||||
|
@State var scrollOffset: CGPoint = .zero
|
||||||
|
|
||||||
let timer = Timer
|
let timer = Timer
|
||||||
.publish(every: 5, on: .main, in: .common)
|
.publish(every: 5, on: .main, in: .common)
|
||||||
@@ -28,15 +31,11 @@ struct SKUnitIntelligentDetailView: View {
|
|||||||
let height = r.size.height
|
let height = r.size.height
|
||||||
let width = r.size.width
|
let width = r.size.width
|
||||||
ScrollViewReader { scrollView in
|
ScrollViewReader { scrollView in
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
OffsetObservingScrollView(
|
||||||
GeometryReader { geometry in
|
axes: .horizontal,
|
||||||
Color.clear
|
showsIndicators: false,
|
||||||
.preference(
|
offset: $scrollOffset
|
||||||
key: ViewOffsetKey.self,
|
) {
|
||||||
value: geometry.frame(in: .named("scrollView")).origin
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.frame(width: 0, height: 0)
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(0 ..< Self.features.count, id: \.self) { featureIndex in
|
ForEach(0 ..< Self.features.count, id: \.self) { featureIndex in
|
||||||
let feature = Self.features[featureIndex]
|
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)
|
.frame(height: height)
|
||||||
.onChange(of: detailIndex) { newValue in
|
.onChange(of: detailIndex) { newValue in
|
||||||
withAnimation(.spring) {
|
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(
|
PageDotsView(
|
||||||
@@ -68,6 +69,12 @@ struct SKUnitIntelligentDetailView: View {
|
|||||||
total: Self.features.count
|
total: Self.features.count
|
||||||
) { index in
|
) { index in
|
||||||
detailIndex = index
|
detailIndex = index
|
||||||
|
let token = UUID()
|
||||||
|
detailIndexInSwitching = token
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
guard detailIndexInSwitching == token else { return }
|
||||||
|
detailIndexInSwitching = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(timer) { _ in
|
.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) {
|
return VStack(alignment: .leading, spacing: 12) {
|
||||||
CategorySelectionView(selectedTab: .pro, onSelect: { _ in })
|
CategorySelectionView(selectedTab: .pro, onSelect: { _ in })
|
||||||
CategorySelectionView(selectedTab: .ai, onSelect: { _ in })
|
CategorySelectionView(selectedTab: .ai, onSelect: { _ in })
|
||||||
CategorySelectionView(selectedTab: .believer, onSelect: { _ in })
|
|
||||||
Divider()
|
Divider()
|
||||||
PreviewWrapper()
|
PreviewWrapper()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ struct HeadlineView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Text(viewModel.selectedUnit.primaryText)
|
Text(viewModel.selectedUnit.primaryText)
|
||||||
.font(.system(size: 24, weight: .heavy))
|
.font(.system(size: 24, weight: .semibold))
|
||||||
.contentTransition(.numericText())
|
.contentTransition(.numericText())
|
||||||
.animation(.spring.speed(2), value: viewModel.category)
|
.animation(.spring.speed(2), value: viewModel.category)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// PricingOptionView.swift
|
// PackageOptionView.swift
|
||||||
// AffinePaywall
|
// AffinePaywall
|
||||||
//
|
//
|
||||||
// Created by qaq on 9/18/25.
|
// Created by qaq on 9/18/25.
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
import AffineResources
|
import AffineResources
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PricingOptionView: View {
|
struct PackageOptionView: View {
|
||||||
let price: String
|
let price: String
|
||||||
let description: String
|
let description: String
|
||||||
var badge: String
|
var badge: String
|
||||||
@@ -102,12 +102,12 @@ struct PricingOptionView: View {
|
|||||||
#Preview {
|
#Preview {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
PricingOptionView(
|
PackageOptionView(
|
||||||
price: "$7.99",
|
price: "$7.99",
|
||||||
description: "Monthly",
|
description: "Monthly",
|
||||||
isSelected: false
|
isSelected: false
|
||||||
) {}
|
) {}
|
||||||
PricingOptionView(
|
PackageOptionView(
|
||||||
price: "$6.75",
|
price: "$6.75",
|
||||||
description: "Annually",
|
description: "Annually",
|
||||||
badge: "Save 15%",
|
badge: "Save 15%",
|
||||||
@@ -115,13 +115,13 @@ struct PricingOptionView: View {
|
|||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
PricingOptionView(
|
PackageOptionView(
|
||||||
price: "$114514",
|
price: "$114514",
|
||||||
description: "Monthly",
|
description: "Monthly",
|
||||||
badge: "Most Popular",
|
badge: "Most Popular",
|
||||||
isSelected: true
|
isSelected: true
|
||||||
) {}
|
) {}
|
||||||
PricingOptionView(
|
PackageOptionView(
|
||||||
price: "$6.75",
|
price: "$6.75",
|
||||||
description: "Annually",
|
description: "Annually",
|
||||||
badge: "Save 15%",
|
badge: "Save 15%",
|
||||||
@@ -11,36 +11,67 @@ import SwiftUI
|
|||||||
struct PurchaseFooterView: View {
|
struct PurchaseFooterView: View {
|
||||||
@StateObject var viewModel: ViewModel
|
@StateObject var viewModel: ViewModel
|
||||||
|
|
||||||
|
var isPurchased: Bool {
|
||||||
|
let package = viewModel.selectePackageOption
|
||||||
|
return viewModel.purchasedItems.contains(package.productIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
if viewModel.availablePricingOptions.count > 1 {
|
if viewModel.availablePackageOptions.count > 1 {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(viewModel.availablePricingOptions) { option in
|
ForEach(viewModel.availablePackageOptions) { option in
|
||||||
PricingOptionView(
|
PackageOptionView(
|
||||||
price: option.price,
|
price: option.price,
|
||||||
description: option.description,
|
description: option.description,
|
||||||
badge: option.badge ?? "",
|
badge: option.badge ?? "",
|
||||||
isSelected: option.id == viewModel.selectedPricingIdentifier
|
isSelected: option.id == viewModel.selectedPackageIdentifier
|
||||||
) {
|
) {
|
||||||
viewModel.select(pricingOption: option)
|
viewModel.select(packageOption: option)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(isPurchased)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if viewModel.updating {
|
||||||
TheGiveMeMoneyButtonView(
|
TheGiveMeMoneyButtonView(
|
||||||
primaryTitle: viewModel.selectedPricingOption.primaryTitle,
|
primaryTitle: "Height Placeholder",
|
||||||
secondaryTitle: viewModel.selectedPricingOption.secondaryTitle,
|
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
|
callback: viewModel.purchase
|
||||||
)
|
)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: viewModel.restore) {
|
Button(action: viewModel.restore) {
|
||||||
|
if isPurchased {
|
||||||
|
Text("Already Purchased")
|
||||||
|
} else {
|
||||||
Text("Restore Purchase")
|
Text("Restore Purchase")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(AffineColors.textSecondary.color)
|
.foregroundStyle(AffineColors.textSecondary.color)
|
||||||
|
.opacity(viewModel.products.isEmpty ? 0 : 1)
|
||||||
|
.disabled(isPurchased)
|
||||||
}
|
}
|
||||||
|
.animation(.spring, value: viewModel.updating)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,20 +11,29 @@ import SwiftUI
|
|||||||
struct TheGiveMeMoneyButtonView: View {
|
struct TheGiveMeMoneyButtonView: View {
|
||||||
let primaryTitle: String
|
let primaryTitle: String
|
||||||
let secondaryTitle: String
|
let secondaryTitle: String
|
||||||
|
let isPurchased: Bool
|
||||||
let callback: () -> Void
|
let callback: () -> Void
|
||||||
|
|
||||||
init(
|
init(
|
||||||
primaryTitle: String = "",
|
primaryTitle: String = "",
|
||||||
secondaryTitle: String = "",
|
secondaryTitle: String = "",
|
||||||
|
isPurchased: Bool,
|
||||||
callback: @escaping () -> Void = {}
|
callback: @escaping () -> Void = {}
|
||||||
) {
|
) {
|
||||||
self.primaryTitle = primaryTitle
|
self.primaryTitle = primaryTitle
|
||||||
self.secondaryTitle = secondaryTitle
|
self.secondaryTitle = secondaryTitle
|
||||||
|
self.isPurchased = isPurchased
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button { callback() } label: {
|
Button { callback() } label: {
|
||||||
|
if isPurchased {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(AffineColors.layerPureWhite.color)
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.padding(12)
|
||||||
|
} else {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
if !primaryTitle.isEmpty {
|
if !primaryTitle.isEmpty {
|
||||||
Text(primaryTitle)
|
Text(primaryTitle)
|
||||||
@@ -42,12 +51,15 @@ struct TheGiveMeMoneyButtonView: View {
|
|||||||
.foregroundColor(AffineColors.layerPureWhite.color)
|
.foregroundColor(AffineColors.layerPureWhite.color)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.animation(.spring, value: primaryTitle)
|
.animation(.spring, value: primaryTitle)
|
||||||
.animation(.spring, value: secondaryTitle)
|
.animation(.spring, value: secondaryTitle)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(minHeight: 32)
|
||||||
.background(AffineColors.buttonPrimary.color)
|
.background(AffineColors.buttonPrimary.color)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.disabled(isPurchased)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,19 +69,28 @@ struct TheGiveMeMoneyButtonView: View {
|
|||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
TheGiveMeMoneyButtonView(
|
TheGiveMeMoneyButtonView(
|
||||||
primaryTitle: "Upgrade for $6.75 per month",
|
primaryTitle: "Upgrade for $6.75 per month",
|
||||||
secondaryTitle: ""
|
secondaryTitle: "",
|
||||||
|
isPurchased: false
|
||||||
)
|
)
|
||||||
TheGiveMeMoneyButtonView(
|
TheGiveMeMoneyButtonView(
|
||||||
primaryTitle: "Upgrade for $10 per month",
|
primaryTitle: "Upgrade for $10 per month",
|
||||||
secondaryTitle: ""
|
secondaryTitle: "",
|
||||||
|
isPurchased: false
|
||||||
)
|
)
|
||||||
TheGiveMeMoneyButtonView(
|
TheGiveMeMoneyButtonView(
|
||||||
primaryTitle: "$8.9 per month",
|
primaryTitle: "$8.9 per month",
|
||||||
secondaryTitle: "billed annually"
|
secondaryTitle: "billed annually",
|
||||||
|
isPurchased: false
|
||||||
)
|
)
|
||||||
TheGiveMeMoneyButtonView(
|
TheGiveMeMoneyButtonView(
|
||||||
primaryTitle: "Upgrade for $499",
|
primaryTitle: "Upgrade for $499",
|
||||||
secondaryTitle: ""
|
secondaryTitle: "",
|
||||||
|
isPurchased: false
|
||||||
|
)
|
||||||
|
TheGiveMeMoneyButtonView(
|
||||||
|
primaryTitle: "Upgrade for $499",
|
||||||
|
secondaryTitle: "",
|
||||||
|
isPurchased: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(32)
|
.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,10 +11,26 @@ import SwiftUI
|
|||||||
struct SKUnitProDetailView: View {
|
struct SKUnitProDetailView: View {
|
||||||
@StateObject var viewModel: ViewModel
|
@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 {
|
var body: some View {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
|
if SKUnitSubcategoryProPlan.allCases.count > 1 {
|
||||||
Picker("Plan", selection: $selection) {
|
Picker("Plan", selection: $selection) {
|
||||||
ForEach(SKUnitSubcategoryProPlan.allCases) { plan in
|
ForEach(SKUnitSubcategoryProPlan.allCases) { plan in
|
||||||
Text(plan.title).tag(plan)
|
Text(plan.title).tag(plan)
|
||||||
@@ -24,22 +40,53 @@ struct SKUnitProDetailView: View {
|
|||||||
.onChange(of: selection) { _ in
|
.onChange(of: selection) { _ in
|
||||||
viewModel.select(subcategory: selection)
|
viewModel.select(subcategory: selection)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HeadlineView(viewModel: viewModel)
|
HeadlineView(viewModel: viewModel)
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
ProFeaturesCardView(
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
features: selection.features,
|
if !headerText.isEmpty {
|
||||||
headerText: selection.headerText
|
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)
|
||||||
}
|
}
|
||||||
.padding(-16)
|
.padding(-16)
|
||||||
|
.animation(.spring.speed(2), value: animationIndex)
|
||||||
|
.onReceive(timer) { _ in animationIndex += 1 }
|
||||||
.frame(
|
.frame(
|
||||||
maxWidth: .infinity,
|
maxWidth: .infinity,
|
||||||
maxHeight: .infinity,
|
maxHeight: .infinity,
|
||||||
alignment: .top
|
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
|
import Foundation
|
||||||
|
|
||||||
struct Feature: Identifiable, Equatable {
|
struct Feature: Identifiable, Equatable, Hashable {
|
||||||
var id = UUID()
|
var id = UUID()
|
||||||
var text: String
|
var text: String
|
||||||
var isHighlighted: Bool // For text like "Everything in AFFINE Pro"
|
var isHighlighted: Bool // For text like "Everything in AFFINE Pro"
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ extension SKUnit {
|
|||||||
category: SKUnitCategory.ai,
|
category: SKUnitCategory.ai,
|
||||||
primaryText: "AFFINE AI",
|
primaryText: "AFFINE AI",
|
||||||
secondaryText: "A true multimodal AI copilot.",
|
secondaryText: "A true multimodal AI copilot.",
|
||||||
pricing: [
|
package: [
|
||||||
SKUnitPricingOption(
|
SKUnitPackageOption(
|
||||||
price: "$8.9 per month",
|
price: "$8.9 per month",
|
||||||
description: "",
|
description: "",
|
||||||
isDefaultSelected: true,
|
isDefaultSelected: true,
|
||||||
primaryTitle: "$8.9 per month",
|
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,
|
subcategory: SKUnitSubcategoryProPlan.default,
|
||||||
primaryText: "Pro",
|
primaryText: "Pro",
|
||||||
secondaryText: "For family and small teams.",
|
secondaryText: "For family and small teams.",
|
||||||
pricing: [
|
package: [
|
||||||
SKUnitPricingOption(
|
SKUnitPackageOption(
|
||||||
price: "$7.99",
|
price: "$7.99",
|
||||||
description: "Monthly",
|
description: "Monthly",
|
||||||
isDefaultSelected: false,
|
isDefaultSelected: false,
|
||||||
primaryTitle: "Upgrade for $7.99/month",
|
primaryTitle: "Upgrade for $7.99/month",
|
||||||
secondaryTitle: ""
|
secondaryTitle: "",
|
||||||
|
productIdentifier: "app.affine.pro.Monthly",
|
||||||
|
revenueCatIdentifier: "app.affine.pro.Monthly"
|
||||||
),
|
),
|
||||||
SKUnitPricingOption(
|
SKUnitPackageOption(
|
||||||
price: "$6.75",
|
price: "$6.75",
|
||||||
description: "Annual",
|
description: "Annual",
|
||||||
badge: "Save 15%",
|
badge: "Save 15%",
|
||||||
isDefaultSelected: true,
|
isDefaultSelected: true,
|
||||||
primaryTitle: "Upgrade for $6.75/month",
|
primaryTitle: "Upgrade for $6.75/month",
|
||||||
secondaryTitle: ""
|
secondaryTitle: "",
|
||||||
),
|
productIdentifier: "app.affine.pro.Annual",
|
||||||
]
|
revenueCatIdentifier: "app.affine.pro.Annual"
|
||||||
),
|
|
||||||
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: ""
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ struct SKUnit: Identifiable, Sendable {
|
|||||||
let subcategory: any SKUnitSubcategorizable
|
let subcategory: any SKUnitSubcategorizable
|
||||||
let primaryText: String
|
let primaryText: String
|
||||||
let secondaryText: String
|
let secondaryText: String
|
||||||
let pricing: [SKUnitPricingOption]
|
let package: [SKUnitPackageOption]
|
||||||
|
|
||||||
init(
|
init(
|
||||||
category: SKUnitCategory,
|
category: SKUnitCategory,
|
||||||
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single,
|
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single,
|
||||||
primaryText: String,
|
primaryText: String,
|
||||||
secondaryText: String,
|
secondaryText: String,
|
||||||
pricing: [SKUnitPricingOption]
|
package: [SKUnitPackageOption]
|
||||||
) {
|
) {
|
||||||
self.category = category
|
self.category = category
|
||||||
self.subcategory = subcategory
|
self.subcategory = subcategory
|
||||||
self.primaryText = primaryText
|
self.primaryText = primaryText
|
||||||
self.secondaryText = secondaryText
|
self.secondaryText = secondaryText
|
||||||
self.pricing = pricing
|
self.package = package
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +34,6 @@ extension SKUnit {
|
|||||||
static let allUnits: [SKUnit] = [
|
static let allUnits: [SKUnit] = [
|
||||||
proUnits,
|
proUnits,
|
||||||
aiUnits,
|
aiUnits,
|
||||||
believerUnits,
|
|
||||||
].flatMap(\.self)
|
].flatMap(\.self)
|
||||||
|
|
||||||
static func units(for category: SKUnitCategory) -> [SKUnit] {
|
static func units(for category: SKUnitCategory) -> [SKUnit] {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
|
|||||||
|
|
||||||
case pro
|
case pro
|
||||||
case ai
|
case ai
|
||||||
case believer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SKUnitCategory {
|
extension SKUnitCategory {
|
||||||
@@ -20,7 +19,6 @@ extension SKUnitCategory {
|
|||||||
switch self {
|
switch self {
|
||||||
case .pro: "AFFINE.Pro"
|
case .pro: "AFFINE.Pro"
|
||||||
case .ai: "AI"
|
case .ai: "AI"
|
||||||
case .believer: "Believer"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// SKUnitPricingOption.swift
|
// SKUnitPackageOption.swift
|
||||||
// AffinePaywall
|
// AffinePaywall
|
||||||
//
|
//
|
||||||
// Created by qaq on 9/18/25.
|
// Created by qaq on 9/18/25.
|
||||||
@@ -7,10 +7,10 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct SKUnitPricingOption: Identifiable, Equatable {
|
struct SKUnitPackageOption: Identifiable, Equatable {
|
||||||
var id: UUID
|
var id: UUID
|
||||||
|
|
||||||
// pricing selection button
|
// package selection button
|
||||||
var price: String
|
var price: String
|
||||||
var description: String
|
var description: String
|
||||||
var badge: String?
|
var badge: String?
|
||||||
@@ -20,6 +20,10 @@ struct SKUnitPricingOption: Identifiable, Equatable {
|
|||||||
var primaryTitle: String
|
var primaryTitle: String
|
||||||
var secondaryTitle: String
|
var secondaryTitle: String
|
||||||
|
|
||||||
|
// product identifiers
|
||||||
|
var productIdentifier: String
|
||||||
|
var revenueCatIdentifier: String
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
price: String,
|
price: String,
|
||||||
@@ -27,7 +31,9 @@ struct SKUnitPricingOption: Identifiable, Equatable {
|
|||||||
badge: String? = nil,
|
badge: String? = nil,
|
||||||
isDefaultSelected: Bool = false,
|
isDefaultSelected: Bool = false,
|
||||||
primaryTitle: String,
|
primaryTitle: String,
|
||||||
secondaryTitle: String
|
secondaryTitle: String,
|
||||||
|
productIdentifier: String,
|
||||||
|
revenueCatIdentifier: String
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.price = price
|
self.price = price
|
||||||
@@ -36,5 +42,7 @@ struct SKUnitPricingOption: Identifiable, Equatable {
|
|||||||
self.isDefaultSelected = isDefaultSelected
|
self.isDefaultSelected = isDefaultSelected
|
||||||
self.primaryTitle = primaryTitle
|
self.primaryTitle = primaryTitle
|
||||||
self.secondaryTitle = secondaryTitle
|
self.secondaryTitle = secondaryTitle
|
||||||
|
self.productIdentifier = productIdentifier
|
||||||
|
self.revenueCatIdentifier = revenueCatIdentifier
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,14 +9,10 @@ import Foundation
|
|||||||
|
|
||||||
enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable {
|
enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable {
|
||||||
case `default`
|
case `default`
|
||||||
case team
|
|
||||||
case selfHost
|
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .default: "Pro"
|
case .default: "Pro"
|
||||||
case .team: "Pro team"
|
|
||||||
case .selfHost: "Self Hosted"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,10 +20,6 @@ enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .default:
|
case .default:
|
||||||
"For family and small teams."
|
"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 {
|
switch self {
|
||||||
case .default:
|
case .default:
|
||||||
"Include in Pro"
|
"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("Community Support"),
|
||||||
Feature("Real-time Syncing & Collaboration for more people"),
|
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 Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
extension ViewModel {
|
extension ViewModel {
|
||||||
func purchase() {
|
func purchase() {
|
||||||
let unit = selectedUnit
|
let unit = selectedUnit
|
||||||
let option = selectedPricingOption
|
let option = selectePackageOption
|
||||||
|
|
||||||
|
assert(!updating)
|
||||||
|
guard !updating else { return }
|
||||||
print(#function, unit, option)
|
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() {
|
func restore() {
|
||||||
let unit = selectedUnit
|
let unit = selectedUnit
|
||||||
let option = selectedPricingOption
|
let option = selectePackageOption
|
||||||
|
|
||||||
|
assert(!updating)
|
||||||
|
guard !updating else { return }
|
||||||
print(#function, unit, option)
|
print(#function, unit, option)
|
||||||
|
|
||||||
|
updateAppStoreStatus(initial: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dismiss() {
|
func dismiss() {
|
||||||
let unit = selectedUnit
|
print(#function)
|
||||||
let option = selectedPricingOption
|
associatedController?.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
print(#function, unit, option)
|
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)
|
vm.select(category: .ai)
|
||||||
return vm
|
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.
|
// Created by qaq on 9/18/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import StoreKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -15,12 +16,30 @@ class ViewModel: ObservableObject {
|
|||||||
|
|
||||||
@Published private(set) var category: SKUnitCategory = .pro
|
@Published private(set) var category: SKUnitCategory = .pro
|
||||||
@Published private(set) var subcategory: any SKUnitSubcategorizable = SKUnitSubcategoryProPlan.default
|
@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,
|
for: .pro,
|
||||||
subcategory: SKUnitSubcategoryProPlan.default
|
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) {
|
func select(category: SKUnitCategory) {
|
||||||
self.category = category
|
self.category = category
|
||||||
@@ -30,7 +49,7 @@ class ViewModel: ObservableObject {
|
|||||||
if !subcategoryExists {
|
if !subcategoryExists {
|
||||||
subcategory = units.first!.subcategory
|
subcategory = units.first!.subcategory
|
||||||
}
|
}
|
||||||
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
|
_ = selectePackageOption // ensure selectePackageOption is valid
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(subcategory: any SKUnitSubcategorizable) {
|
func select(subcategory: any SKUnitSubcategorizable) {
|
||||||
@@ -45,20 +64,20 @@ class ViewModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.subcategory = subcategory
|
self.subcategory = subcategory
|
||||||
}
|
}
|
||||||
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
|
_ = selectePackageOption // ensure selectePackageOption is valid
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(pricingOption option: SKUnitPricingOption) {
|
func select(packageOption option: SKUnitPackageOption) {
|
||||||
selectedPricingIdentifier = option.id
|
selectedPackageIdentifier = option.id
|
||||||
|
|
||||||
let unit = availableUnits
|
let unit = availableUnits
|
||||||
.first { unit in
|
.first { unit in
|
||||||
unit.pricing.contains { $0.id == option.id }
|
unit.package.contains { $0.id == option.id }
|
||||||
}!
|
}!
|
||||||
category = unit.category
|
category = unit.category
|
||||||
subcategory = unit.subcategory
|
subcategory = unit.subcategory
|
||||||
|
|
||||||
_ = selectedPricingOption // ensure selectedPricingIdentifier is valid
|
_ = selectePackageOption // ensure selectePackageOption is valid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,21 +98,21 @@ extension ViewModel {
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedPricingOption: SKUnitPricingOption {
|
var selectePackageOption: SKUnitPackageOption {
|
||||||
let item = selectedUnit.pricing
|
let item = selectedUnit.package
|
||||||
.first { $0.id == selectedPricingIdentifier }
|
.first { $0.id == selectedPackageIdentifier }
|
||||||
if let item { return item }
|
if let item { return item }
|
||||||
let defaultItem = selectedUnit.pricing.first { $0.isDefaultSelected }
|
let defaultItem = selectedUnit.package.first { $0.isDefaultSelected }
|
||||||
if let defaultItem {
|
if let defaultItem {
|
||||||
selectedPricingIdentifier = defaultItem.id
|
selectedPackageIdentifier = defaultItem.id
|
||||||
return defaultItem
|
return defaultItem
|
||||||
}
|
}
|
||||||
let lastItem = selectedUnit.pricing.last!
|
let lastItem = selectedUnit.package.last!
|
||||||
selectedPricingIdentifier = lastItem.id
|
selectedPackageIdentifier = lastItem.id
|
||||||
return lastItem
|
return lastItem
|
||||||
}
|
}
|
||||||
|
|
||||||
var availablePricingOptions: [SKUnitPricingOption] {
|
var availablePackageOptions: [SKUnitPackageOption] {
|
||||||
selectedUnit.pricing
|
selectedUnit.package
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ struct AffinePaywallPageView: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
@State private var presentAnimation = false
|
||||||
|
@State private var showCloseButton = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -24,10 +27,17 @@ struct AffinePaywallPageView: View {
|
|||||||
Button {
|
Button {
|
||||||
viewModel.dismiss()
|
viewModel.dismiss()
|
||||||
} label: {
|
} label: {
|
||||||
Image(AffineIcons.close.rawValue)
|
AffineIcons.close.image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundColor(AffineColors.textSecondary.color)
|
.foregroundColor(AffineColors.textSecondary.color)
|
||||||
|
.opacity(showCloseButton ? 1 : 0)
|
||||||
|
.disabled(!showCloseButton)
|
||||||
|
.animation(.spring, value: showCloseButton)
|
||||||
}
|
}
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -45,11 +55,22 @@ struct AffinePaywallPageView: View {
|
|||||||
.animation(.spring.speed(2), value: viewModel.category)
|
.animation(.spring.speed(2), value: viewModel.category)
|
||||||
|
|
||||||
PurchaseFooterView(viewModel: viewModel)
|
PurchaseFooterView(viewModel: viewModel)
|
||||||
.animation(.spring.speed(2), value: viewModel.selectedPricingIdentifier)
|
.animation(.spring.speed(2), value: viewModel.selectedPackageIdentifier)
|
||||||
}
|
}
|
||||||
.padding()
|
.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(
|
.background(
|
||||||
AffineColors.layerBackgroundSecondary.color
|
AffineColors.layerBackgroundSecondary.color
|
||||||
|
.ignoresSafeArea()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +81,6 @@ struct AffinePaywallPageView: View {
|
|||||||
SKUnitProDetailView(viewModel: viewModel)
|
SKUnitProDetailView(viewModel: viewModel)
|
||||||
case .ai:
|
case .ai:
|
||||||
SKUnitIntelligentDetailView(viewModel: viewModel)
|
SKUnitIntelligentDetailView(viewModel: viewModel)
|
||||||
case .believer:
|
|
||||||
SKUnitBelieverDetailView(viewModel: viewModel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
//
|
//
|
||||||
// File.swift
|
// Paywall.swift
|
||||||
// AffinePaywall
|
// AffinePaywall
|
||||||
//
|
//
|
||||||
// Created by qaq on 9/18/25.
|
// Created by qaq on 9/18/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
public enum Paywall {
|
public enum Paywall {
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -15,13 +15,19 @@ public enum Paywall {
|
|||||||
type: String
|
type: String
|
||||||
) {
|
) {
|
||||||
let viewModel = ViewModel()
|
let viewModel = ViewModel()
|
||||||
switch type {
|
switch type.lowercased() {
|
||||||
// TODO: FIGURE OUT PAYWALL TYPES
|
case "pro":
|
||||||
|
viewModel.select(category: .pro)
|
||||||
|
viewModel.select(subcategory: SKUnitSubcategoryProPlan.default)
|
||||||
|
case "ai":
|
||||||
|
viewModel.select(category: .ai)
|
||||||
|
viewModel.select(subcategory: SKUnitSingleSubcategory.single)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
let view = AffinePaywallPageView(viewModel: viewModel)
|
let view = AffinePaywallPageView(viewModel: viewModel)
|
||||||
let hostingController = UIHostingController(rootView: view)
|
let hostingController = UIHostingController(rootView: view)
|
||||||
|
viewModel.bind(controller: hostingController)
|
||||||
hostingController.modalPresentationStyle = .overFullScreen
|
hostingController.modalPresentationStyle = .overFullScreen
|
||||||
hostingController.modalTransitionStyle = .coverVertical
|
hostingController.modalTransitionStyle = .coverVertical
|
||||||
hostingController.preferredContentSize = CGSize(width: 555, height: 555) // for iPads
|
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