feat(ios): polish onboarding and native sign-in flow (#15217)

## Summary
- Reworks the iOS onboarding, native sign-in, and paywall flow so
first-install and cold-start entry stay native, adaptive, and aligned
with the current login/subscription gates.
- Removes onboarding swipe paging, refines onboarding/paywall layout
behavior, and keeps AI/paywall entry behavior consistent for logged-in
and subscribed users.
- Adds the new mobile all-docs empty states with localized copy and
dialog entry points, and closes the remaining review follow-ups by
removing the onboarding plan artifact and dropping the iOS AI
subscription bypass.

## Test plan
- Built the iOS app for simulator with `xcodebuild -workspace
App.xcworkspace -scheme App -configuration Debug -sdk iphonesimulator
-destination 'generic/platform=iOS Simulator' ARCHS=arm64
ONLY_ACTIVE_ARCH=YES CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO
build` during the native onboarding/sign-in flow work.
- Let the repo pre-commit hooks run on the latest follow-up commit
(`prettier` + `eslint --fix`).
- Checked diagnostics for the edited TS/TSX files after the review
follow-up changes.
- Manually iterated on onboarding/native sign-in/paywall UI states in
simulator during implementation.

---------

Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
keepClamDown
2026-08-21 08:12:27 +08:00
committed by GitHub
parent c57004ea2c
commit 73e2d10973
130 changed files with 6478 additions and 2268 deletions
@@ -17,6 +17,23 @@
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 */; };
50B000052F01000000000000 /* OnboardingFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B000022F01000000000000 /* OnboardingFlag.swift */; };
50B000062F01000000000000 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B000032F01000000000000 /* OnboardingViewController.swift */; };
50B000072F01000000000000 /* OnboardingRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B000042F01000000000000 /* OnboardingRootView.swift */; };
50B000082F01000000000000 /* AppPaywallViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B000092F01000000000000 /* AppPaywallViewController.swift */; };
50B0000E2F01000000000000 /* OnboardingIntroViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B0000F2F01000000000000 /* OnboardingIntroViews.swift */; };
50B000102F01000000000000 /* OnboardingFeatureViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B000112F01000000000000 /* OnboardingFeatureViews.swift */; };
50B000122F01000000000000 /* AppPaywallStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B000132F01000000000000 /* AppPaywallStyle.swift */; };
50B000142F01000000000000 /* AppPaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B000152F01000000000000 /* AppPaywallView.swift */; };
50B000162F01000000000000 /* AppPaywallCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B000172F01000000000000 /* AppPaywallCards.swift */; };
50C000032F02000000000000 /* NativeSignInViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C000022F02000000000000 /* NativeSignInViewController.swift */; };
50C0000A2F02000000000000 /* NativeSignInViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C0000B2F02000000000000 /* NativeSignInViewModel.swift */; };
50C0000C2F02000000000000 /* NativeSignInStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C0000D2F02000000000000 /* NativeSignInStyle.swift */; };
50C0000E2F02000000000000 /* NativeSignInWebBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C0000F2F02000000000000 /* NativeSignInWebBridge.swift */; };
50C000102F02000000000000 /* NativeSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C000112F02000000000000 /* NativeSignInView.swift */; };
50C000122F02000000000000 /* NativeSignInHUD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C000132F02000000000000 /* NativeSignInHUD.swift */; };
50C000142F02000000000000 /* NativeSignInComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C000152F02000000000000 /* NativeSignInComponents.swift */; };
50D000032F03000000000000 /* ColdStartSignInSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D000022F03000000000000 /* ColdStartSignInSheetViewController.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 */; };
9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */; };
@@ -61,7 +78,24 @@
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; };
50B000022F01000000000000 /* OnboardingFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFlag.swift; sourceTree = "<group>"; };
50B000032F01000000000000 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
50B000042F01000000000000 /* OnboardingRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingRootView.swift; sourceTree = "<group>"; };
50B000092F01000000000000 /* AppPaywallViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPaywallViewController.swift; sourceTree = "<group>"; };
50B0000F2F01000000000000 /* OnboardingIntroViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingIntroViews.swift; sourceTree = "<group>"; };
50B000112F01000000000000 /* OnboardingFeatureViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFeatureViews.swift; sourceTree = "<group>"; };
50B000132F01000000000000 /* AppPaywallStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPaywallStyle.swift; sourceTree = "<group>"; };
50B000152F01000000000000 /* AppPaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPaywallView.swift; sourceTree = "<group>"; };
50B000172F01000000000000 /* AppPaywallCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPaywallCards.swift; sourceTree = "<group>"; };
50BBC54E2E840DBC0067C5E2 /* Products.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Products.storekit; sourceTree = "<group>"; };
50C000022F02000000000000 /* NativeSignInViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInViewController.swift; sourceTree = "<group>"; };
50C0000B2F02000000000000 /* NativeSignInViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInViewModel.swift; sourceTree = "<group>"; };
50C0000D2F02000000000000 /* NativeSignInStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInStyle.swift; sourceTree = "<group>"; };
50C0000F2F02000000000000 /* NativeSignInWebBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInWebBridge.swift; sourceTree = "<group>"; };
50C000112F02000000000000 /* NativeSignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInView.swift; sourceTree = "<group>"; };
50C000132F02000000000000 /* NativeSignInHUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInHUD.swift; sourceTree = "<group>"; };
50C000152F02000000000000 /* NativeSignInComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInComponents.swift; sourceTree = "<group>"; };
50D000022F03000000000000 /* ColdStartSignInSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColdStartSignInSheetViewController.swift; 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>"; };
@@ -171,6 +205,36 @@
path = Packages;
sourceTree = "<group>";
};
50B000012F01000000000000 /* Onboarding */ = {
isa = PBXGroup;
children = (
50B000022F01000000000000 /* OnboardingFlag.swift */,
50B000032F01000000000000 /* OnboardingViewController.swift */,
50B000042F01000000000000 /* OnboardingRootView.swift */,
50B000092F01000000000000 /* AppPaywallViewController.swift */,
50B0000F2F01000000000000 /* OnboardingIntroViews.swift */,
50B000112F01000000000000 /* OnboardingFeatureViews.swift */,
50B000132F01000000000000 /* AppPaywallStyle.swift */,
50B000152F01000000000000 /* AppPaywallView.swift */,
50B000172F01000000000000 /* AppPaywallCards.swift */,
);
path = Onboarding;
sourceTree = "<group>";
};
50C000012F02000000000000 /* SignIn */ = {
isa = PBXGroup;
children = (
50C000022F02000000000000 /* NativeSignInViewController.swift */,
50C0000B2F02000000000000 /* NativeSignInViewModel.swift */,
50C0000D2F02000000000000 /* NativeSignInStyle.swift */,
50C0000F2F02000000000000 /* NativeSignInWebBridge.swift */,
50C000112F02000000000000 /* NativeSignInView.swift */,
50C000132F02000000000000 /* NativeSignInHUD.swift */,
50C000152F02000000000000 /* NativeSignInComponents.swift */,
);
path = SignIn;
sourceTree = "<group>";
};
7F8756D8B27F46E3366F6CEA /* Pods */ = {
isa = PBXGroup;
children = (
@@ -188,9 +252,12 @@
9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */,
9DEC59422D323EE00027CEBD /* Mutex.swift */,
9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */,
50B000012F01000000000000 /* Onboarding */,
50C000012F02000000000000 /* SignIn */,
9DAE85B72E7BAC3B00DB9F1D /* Plugins */,
9D90BE1C2CCB9876006677DB /* AppDelegate.swift */,
507513692D1924C600AD60C0 /* RootViewController.swift */,
50D000022F03000000000000 /* ColdStartSignInSheetViewController.swift */,
9D90BE1B2CCB9876006677DB /* AffineViewController.swift */,
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */,
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */,
@@ -251,6 +318,12 @@
9DAE85B72E7BAC3B00DB9F1D /* Plugins */,
);
name = AFFiNE;
packageProductDependencies = (
500D72F62E825064006E553F /* Intelligents */,
5027D4772E7C5FBD00ADD25A /* AffinePaywall */,
5027D4792E7C5FC100ADD25A /* AffineResources */,
5027D47B2E7C5FC400ADD25A /* AffineGraphQL */,
);
productName = App;
productReference = 504EC3041FED79650016851F /* AFFiNE.app */;
productType = "com.apple.product-type.application";
@@ -299,6 +372,12 @@
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
50B0000A2F01000000000000 /* XCLocalSwiftPackageReference "Intelligents" */,
50B0000B2F01000000000000 /* XCLocalSwiftPackageReference "AffinePaywall" */,
50B0000C2F01000000000000 /* XCLocalSwiftPackageReference "AffineResources" */,
50B0000D2F01000000000000 /* XCLocalSwiftPackageReference "AffineGraphQL" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
@@ -393,8 +472,25 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
50B000052F01000000000000 /* OnboardingFlag.swift in Sources */,
50B000062F01000000000000 /* OnboardingViewController.swift in Sources */,
50B000072F01000000000000 /* OnboardingRootView.swift in Sources */,
50B000082F01000000000000 /* AppPaywallViewController.swift in Sources */,
50B0000E2F01000000000000 /* OnboardingIntroViews.swift in Sources */,
50B000102F01000000000000 /* OnboardingFeatureViews.swift in Sources */,
50B000122F01000000000000 /* AppPaywallStyle.swift in Sources */,
50B000142F01000000000000 /* AppPaywallView.swift in Sources */,
50B000162F01000000000000 /* AppPaywallCards.swift in Sources */,
50C000032F02000000000000 /* NativeSignInViewController.swift in Sources */,
50C0000A2F02000000000000 /* NativeSignInViewModel.swift in Sources */,
50C0000C2F02000000000000 /* NativeSignInStyle.swift in Sources */,
50C0000E2F02000000000000 /* NativeSignInWebBridge.swift in Sources */,
50C000102F02000000000000 /* NativeSignInView.swift in Sources */,
50C000122F02000000000000 /* NativeSignInHUD.swift in Sources */,
50C000142F02000000000000 /* NativeSignInComponents.swift in Sources */,
9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */,
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */,
50D000032F03000000000000 /* ColdStartSignInSheetViewController.swift in Sources */,
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */,
9DAE9BD92D8D1AB0000C1D5A /* AppConfigManager.swift in Sources */,
50FF428A2D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift in Sources */,
@@ -695,21 +791,44 @@
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
50B0000A2F01000000000000 /* XCLocalSwiftPackageReference "Intelligents" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Packages/Intelligents;
};
50B0000B2F01000000000000 /* XCLocalSwiftPackageReference "AffinePaywall" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Packages/AffinePaywall;
};
50B0000C2F01000000000000 /* XCLocalSwiftPackageReference "AffineResources" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Packages/AffineResources;
};
50B0000D2F01000000000000 /* XCLocalSwiftPackageReference "AffineGraphQL" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Packages/AffineGraphQL;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
500D72F62E825064006E553F /* Intelligents */ = {
isa = XCSwiftPackageProductDependency;
package = 50B0000A2F01000000000000 /* XCLocalSwiftPackageReference "Intelligents" */;
productName = Intelligents;
};
5027D4772E7C5FBD00ADD25A /* AffinePaywall */ = {
isa = XCSwiftPackageProductDependency;
package = 50B0000B2F01000000000000 /* XCLocalSwiftPackageReference "AffinePaywall" */;
productName = AffinePaywall;
};
5027D4792E7C5FC100ADD25A /* AffineResources */ = {
isa = XCSwiftPackageProductDependency;
package = 50B0000C2F01000000000000 /* XCLocalSwiftPackageReference "AffineResources" */;
productName = AffineResources;
};
5027D47B2E7C5FC400ADD25A /* AffineGraphQL */ = {
isa = XCSwiftPackageProductDependency;
package = 50B0000D2F01000000000000 /* XCLocalSwiftPackageReference "AffineGraphQL" */;
productName = AffineGraphQL;
};
/* End XCSwiftPackageProductDependency section */
@@ -7,6 +7,7 @@
import Intelligents
import UIKit
import WebKit
extension AFFiNEViewController: IntelligentsButtonDelegate {
private static let aiConsentKey = "com.affine.intelligents.userConsented"
@@ -15,30 +16,121 @@ extension AFFiNEViewController: IntelligentsButtonDelegate {
UserDefaults.standard.bool(forKey: Self.aiConsentKey)
}
func onIntelligentsButtonTapped(_: IntelligentsButton) {
if hasUserConsented {
presentIntelligentsController()
return
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
button.beginProgress()
Task { @MainActor [weak self, weak button] in
guard let self else {
button?.stopProgress()
return
}
await self.handleIntelligentsButtonTapped(tappedButton: button)
}
showAIConsentAlert()
}
@MainActor
private func handleIntelligentsButtonTapped(tappedButton: IntelligentsButton?) async {
guard let webView else {
tappedButton?.stopProgress()
return
}
do {
let isSignedIn = try await PaywallAuthGuard.ensureSignedIn(using: webView)
guard isSignedIn else {
tappedButton?.stopProgress()
dismissIntelligentsButton()
return
}
if try await PaywallAuthGuard.hasAISubscription(in: webView) {
tappedButton?.stopProgress()
continueToIntelligentsController()
return
}
tappedButton?.stopProgress()
presentAIPaywall(bindWebView: webView)
} catch {
tappedButton?.stopProgress()
showAIErrorAlert(error)
}
}
@MainActor
private func continueToIntelligentsController() {
if hasUserConsented {
prepareAndPresentIntelligentsController()
return
}
showAIConsentAlert { [weak self] in
self?.prepareAndPresentIntelligentsController()
}
}
@MainActor
private func prepareAndPresentIntelligentsController() {
intelligentsButton?.beginProgress()
IntelligentContext.shared.webView = webView
IntelligentContext.shared.preparePresent { [weak self] result in
DispatchQueue.main.async {
guard let self else { return }
self.intelligentsButton?.stopProgress()
switch result {
case .success:
self.presentIntelligentsController()
case let .failure(error):
self.showAIErrorAlert(error)
}
}
}
}
@MainActor
private func presentIntelligentsController() {
let controller = IntelligentsController()
present(controller, animated: true)
}
private func showAIConsentAlert() {
@MainActor
private func presentAIPaywall(bindWebView webView: WKWebView) {
guard presentedViewController == nil else { return }
let paywallController = AppPaywallViewController()
paywallController.initialPlan = .ai
paywallController.bindWebView = webView
paywallController.modalPresentationStyle = .fullScreen
paywallController.modalTransitionStyle = .coverVertical
paywallController.onPurchaseCompleted = { [weak self] in
Task { @MainActor in
self?.continueToIntelligentsController()
}
}
present(paywallController, animated: true)
}
@MainActor
private func showAIConsentAlert(onContinue: @escaping () -> Void) {
let alert = UIAlertController(
title: "AI Feature Data Usage",
message: "To provide AI-powered features, your input (such as document content and conversation messages) will be sent to our third-party AI service providers (Google, Anthropic, or OpenAI, based on your choice) for processing. This data is used solely to generate responses and is not used for any other purpose.\n\nBy continuing, you agree to share this data with these AI services.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Agree & Continue", style: .default) { [weak self] _ in
alert.addAction(UIAlertAction(title: "Agree & Continue", style: .default) { _ in
UserDefaults.standard.set(true, forKey: Self.aiConsentKey)
self?.presentIntelligentsController()
onContinue()
})
present(alert, animated: true)
}
@MainActor
private func showAIErrorAlert(_ error: Error) {
let alert = UIAlertController(
title: "Unable to open AFFiNE AI",
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
present(alert, animated: true)
}
}
@@ -75,6 +75,8 @@ class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate, Affin
plugins.forEach { bridge?.registerPluginInstance($0) }
}
private static let intelligentsButtonRefreshInterval: TimeInterval = 0.5
private var intelligentsButtonTimer: Timer?
private var isCheckingIntelligentEligibility = false
@@ -82,7 +84,8 @@ class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate, Affin
super.viewDidAppear(animated)
IntelligentContext.shared.webView = webView
navigationController?.setNavigationBarHidden(false, animated: animated)
let timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in
checkEligibilityOfIntelligent()
let timer = Timer.scheduledTimer(withTimeInterval: Self.intelligentsButtonRefreshInterval, repeats: true) { [weak self] _ in
self?.checkEligibilityOfIntelligent()
}
intelligentsButtonTimer = timer
@@ -92,16 +95,40 @@ class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate, Affin
private func checkEligibilityOfIntelligent() {
guard !isCheckingIntelligentEligibility else { return }
assert(intelligentsButton != nil)
guard intelligentsButton?.isHidden ?? false else { return }
guard let webView else {
if intelligentsButton?.isHidden == false {
dismissIntelligentsButton()
}
return
}
isCheckingIntelligentEligibility = true
IntelligentContext.shared.webView = webView
IntelligentContext.shared.preparePresent { [self] result in
DispatchQueue.main.async {
defer { self.isCheckingIntelligentEligibility = false }
switch result {
case .failure: break
case .success:
self.presentIntelligentsButton()
Task { @MainActor [weak self, webView] in
guard let self else { return }
guard await PaywallAuthGuard.currentUserIdentifier(in: webView) != nil else {
if self.intelligentsButton?.isHidden == false {
self.dismissIntelligentsButton()
}
self.isCheckingIntelligentEligibility = false
return
}
guard self.intelligentsButton?.isHidden ?? false else {
self.isCheckingIntelligentEligibility = false
return
}
IntelligentContext.shared.webView = webView
IntelligentContext.shared.preparePresent(createSession: false) { [weak self] result in
DispatchQueue.main.async {
guard let self else { return }
defer { self.isCheckingIntelligentEligibility = false }
switch result {
case .failure: break
case .success:
self.presentIntelligentsButton()
}
}
}
}
@@ -18,12 +18,14 @@ enum ApplicationBridgedWindowScript: String {
case getCurrentWorkspaceId = "window.getCurrentWorkspaceId();"
case getCurrentDocId = "window.getCurrentDocId();"
case getCurrentI18nLocale = "window.getCurrentI18nLocale();"
case getCurrentUserIdentifier = "return await window.getCurrentUserIdentifier();"
case requestSignIn = "return await window.requestSignIn();"
case getCurrentThemeMode = "window.getCurrentThemeMode();"
case createNewDocByMarkdownInCurrentWorkspace = "return await window.createNewDocByMarkdownInCurrentWorkspace(markdown, title);"
var requiresAsyncContext: Bool {
switch self {
case .getCurrentDocContentInMarkdown, .createNewDocByMarkdownInCurrentWorkspace: true
case .getCurrentDocContentInMarkdown, .getCurrentUserIdentifier, .requestSignIn, .createNewDocByMarkdownInCurrentWorkspace: true
default: false
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "native-login-background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "native-login-logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF3",
"green" : "0xF3",
"red" : "0xF3"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x25",
"green" : "0x25",
"red" : "0x25"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-feature-ai.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-feature-bigger-picture.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-feature-clear-docs.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-feature-every-device.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-feature-multiple-views.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFE",
"green" : "0xFE",
"red" : "0xFE"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x25",
"green" : "0x25",
"red" : "0x25"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-intro-reference.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-role-educator.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-role-other.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-role-professional.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-role-student.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,336 @@
//
// ColdStartSignInSheetViewController.swift
// App
//
import AffineResources
import UIKit
final class ColdStartSignInSheetViewController: UIViewController {
private static let portraitSheetHeight: CGFloat = 336
private static let landscapeSheetHeight: CGFloat = 284
private static let boltColor = UIColor(red: 1, green: 195 / 255, blue: 0, alpha: 1)
private static var cachedUserInterfaceStyle: UIUserInterfaceStyle {
switch UserDefaults.standard.string(forKey: AffineThemeStorage.modeKey) {
case "dark":
return .dark
case "light":
return .light
default:
return .unspecified
}
}
enum Action {
case seeProBenefits
case continueFree
}
var onAction: ((Action) -> Void)?
private var didResolve = false
private let dimmingView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = UIColor.black.withAlphaComponent(0.28)
view.alpha = 0
return view
}()
private let sheetView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .systemBackground
view.layer.cornerRadius = 24
view.layer.cornerCurve = .continuous
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
view.clipsToBounds = true
return view
}()
private var sheetBottomConstraint: NSLayoutConstraint?
private var sheetHeightConstraint: NSLayoutConstraint?
private var stackTopConstraint: NSLayoutConstraint?
private var stackCenterYConstraint: NSLayoutConstraint?
private var logoWidthConstraint: NSLayoutConstraint?
private var logoHeightConstraint: NSLayoutConstraint?
private var buttonHeightConstraint: NSLayoutConstraint?
private var continueFreeHeightConstraint: NSLayoutConstraint?
private var didShowSheet = false
private var isLandscapeLayout: Bool {
view.bounds.width > view.bounds.height
}
private var currentSheetHeight: CGFloat {
isLandscapeLayout ? Self.landscapeSheetHeight : Self.portraitSheetHeight
}
private let logoImageView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "NativeLoginLogo")?.withRenderingMode(.alwaysTemplate))
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.setContentHuggingPriority(.required, for: .vertical)
return imageView
}()
private let titleLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = String(localized: "Create here")
label.textAlignment = .left
label.textColor = .label
label.font = .systemFont(ofSize: 26, weight: .bold)
return label
}()
private let subtitleLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = String(localized: "Continue anywhere. Work across\niPhone, iPad, and desktop.")
label.textAlignment = .center
label.textColor = .secondaryLabel
label.font = .systemFont(ofSize: 20, weight: .regular)
label.numberOfLines = 0
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.86
return label
}()
private lazy var proBenefitsButton: UIButton = {
var title = AttributedString(localized: "See pro benefits")
title.font = .systemFont(ofSize: 22, weight: .bold)
var configuration = UIButton.Configuration.filled()
configuration.attributedTitle = title
configuration.image = UIImage(systemName: "bolt.fill")?.withTintColor(Self.boltColor, renderingMode: .alwaysOriginal)
configuration.imagePlacement = .trailing
configuration.imagePadding = 10
configuration.baseForegroundColor = .white
configuration.baseBackgroundColor = AffineColors.buttonPrimary.uiColor
configuration.cornerStyle = .fixed
configuration.background.cornerRadius = 10
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)
let button = UIButton(configuration: configuration)
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: #selector(handleProBenefitsTapped), for: .touchUpInside)
return button
}()
private lazy var continueFreeButton: UIButton = {
let button = UIButton(type: .system)
button.translatesAutoresizingMaskIntoConstraints = false
button.titleLabel?.font = .systemFont(ofSize: 20, weight: .medium)
button.setTitle(String(localized: "Continue free"), for: .normal)
button.setTitleColor(.tertiaryLabel, for: .normal)
button.addTarget(self, action: #selector(handleContinueFreeTapped), for: .touchUpInside)
return button
}()
init() {
super.init(nibName: nil, bundle: nil)
overrideUserInterfaceStyle = Self.cachedUserInterfaceStyle
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
installContent()
updateColors()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
showSheet()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updateLayoutForCurrentSize()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updateColors()
}
private func installContent() {
let headerStackView = UIStackView(arrangedSubviews: [logoImageView, titleLabel])
headerStackView.translatesAutoresizingMaskIntoConstraints = false
headerStackView.axis = .horizontal
headerStackView.alignment = .center
headerStackView.spacing = 12
let stackView = UIStackView(arrangedSubviews: [
headerStackView,
subtitleLabel,
proBenefitsButton,
continueFreeButton,
])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.alignment = .center
stackView.spacing = 12
stackView.setCustomSpacing(18, after: headerStackView)
stackView.setCustomSpacing(30, after: subtitleLabel)
stackView.setCustomSpacing(14, after: proBenefitsButton)
dimmingView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleContinueFreeTapped)))
view.addSubview(dimmingView)
view.addSubview(sheetView)
sheetView.addSubview(stackView)
sheetBottomConstraint = sheetView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: currentSheetHeight)
sheetHeightConstraint = sheetView.heightAnchor.constraint(equalToConstant: currentSheetHeight)
stackTopConstraint = stackView.topAnchor.constraint(greaterThanOrEqualTo: sheetView.topAnchor, constant: isLandscapeLayout ? 16 : 26)
stackCenterYConstraint = stackView.centerYAnchor.constraint(equalTo: sheetView.centerYAnchor, constant: isLandscapeLayout ? -8 : -10)
logoWidthConstraint = logoImageView.widthAnchor.constraint(equalToConstant: isLandscapeLayout ? 34 : 44)
logoHeightConstraint = logoImageView.heightAnchor.constraint(equalToConstant: isLandscapeLayout ? 34 : 44)
buttonHeightConstraint = proBenefitsButton.heightAnchor.constraint(equalToConstant: isLandscapeLayout ? 48 : 58)
continueFreeHeightConstraint = continueFreeButton.heightAnchor.constraint(equalToConstant: isLandscapeLayout ? 28 : 32)
NSLayoutConstraint.activate([
dimmingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
dimmingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
dimmingView.topAnchor.constraint(equalTo: view.topAnchor),
dimmingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
sheetView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
sheetView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
sheetBottomConstraint!,
sheetHeightConstraint!,
stackTopConstraint!,
stackCenterYConstraint!,
stackView.centerXAnchor.constraint(equalTo: sheetView.centerXAnchor),
stackView.leadingAnchor.constraint(greaterThanOrEqualTo: sheetView.leadingAnchor, constant: 31),
stackView.trailingAnchor.constraint(lessThanOrEqualTo: sheetView.trailingAnchor, constant: -31),
stackView.bottomAnchor.constraint(lessThanOrEqualTo: sheetView.safeAreaLayoutGuide.bottomAnchor, constant: -8),
logoWidthConstraint!,
logoHeightConstraint!,
subtitleLabel.widthAnchor.constraint(lessThanOrEqualTo: sheetView.widthAnchor, constant: -62),
proBenefitsButton.leadingAnchor.constraint(equalTo: sheetView.leadingAnchor, constant: 31),
proBenefitsButton.trailingAnchor.constraint(equalTo: sheetView.trailingAnchor, constant: -31),
buttonHeightConstraint!,
continueFreeHeightConstraint!,
])
}
private func updateColors() {
let traits = traitCollection
let isDark = traits.userInterfaceStyle == .dark
dimmingView.backgroundColor = UIColor.black.withAlphaComponent(isDark ? 0.48 : 0.28)
sheetView.backgroundColor = AffineColors.layerBackgroundPrimary.uiColor.resolvedColor(with: traits)
logoImageView.tintColor = AffineColors.textPrimary.uiColor.resolvedColor(with: traits)
titleLabel.textColor = AffineColors.textPrimary.uiColor.resolvedColor(with: traits)
subtitleLabel.textColor = AffineColors.textSecondary.uiColor.resolvedColor(with: traits)
continueFreeButton.setTitleColor(AffineColors.textTertiary.uiColor.resolvedColor(with: traits), for: .normal)
updateProBenefitsButtonConfiguration()
}
private func updateLayoutForCurrentSize() {
let isLandscape = isLandscapeLayout
sheetHeightConstraint?.constant = currentSheetHeight
sheetBottomConstraint?.constant = didShowSheet ? 0 : currentSheetHeight
stackTopConstraint?.constant = isLandscape ? 16 : 26
stackCenterYConstraint?.constant = isLandscape ? -8 : -10
logoWidthConstraint?.constant = isLandscape ? 34 : 44
logoHeightConstraint?.constant = isLandscape ? 34 : 44
buttonHeightConstraint?.constant = isLandscape ? 48 : 58
continueFreeHeightConstraint?.constant = isLandscape ? 28 : 32
titleLabel.font = .systemFont(ofSize: isLandscape ? 22 : 26, weight: .bold)
subtitleLabel.font = .systemFont(ofSize: isLandscape ? 17 : 20, weight: .regular)
continueFreeButton.titleLabel?.font = .systemFont(ofSize: isLandscape ? 18 : 20, weight: .medium)
updateProBenefitsButtonConfiguration()
}
private func updateProBenefitsButtonConfiguration() {
var title = AttributedString(localized: "See pro benefits")
title.font = .systemFont(ofSize: isLandscapeLayout ? 19 : 22, weight: .bold)
var configuration = proBenefitsButton.configuration ?? UIButton.Configuration.filled()
configuration.attributedTitle = title
configuration.image = UIImage(systemName: "bolt.fill")?.withTintColor(Self.boltColor, renderingMode: .alwaysOriginal)
configuration.imagePlacement = .trailing
configuration.imagePadding = 10
configuration.baseForegroundColor = .white
configuration.baseBackgroundColor = AffineColors.buttonPrimary.uiColor.resolvedColor(with: traitCollection)
configuration.cornerStyle = .fixed
configuration.background.cornerRadius = 10
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)
proBenefitsButton.configuration = configuration
}
private func showSheet() {
guard !didShowSheet else { return }
didShowSheet = true
view.layoutIfNeeded()
sheetBottomConstraint?.constant = 0
UIView.animate(
withDuration: 0.28,
delay: 0,
usingSpringWithDamping: 0.92,
initialSpringVelocity: 0,
options: [.curveEaseOut]
) { [weak self] in
self?.dimmingView.alpha = 1
self?.view.layoutIfNeeded()
}
}
private func hideSheet(completion: @escaping () -> Void) {
sheetBottomConstraint?.constant = currentSheetHeight
UIView.animate(
withDuration: 0.22,
delay: 0,
options: [.curveEaseIn]
) { [weak self] in
self?.dimmingView.alpha = 0
self?.view.layoutIfNeeded()
} completion: { _ in
completion()
}
}
@objc
private func handleProBenefitsTapped() {
complete(.seeProBenefits, dismissFirst: true)
}
@objc
private func handleContinueFreeTapped() {
complete(.continueFree, dismissFirst: true)
}
private func complete(_ action: Action, dismissFirst: Bool) {
guard !didResolve else { return }
didResolve = true
if dismissFirst {
hideSheet { [weak self, onAction] in
self?.dismiss(animated: false) {
onAction?(action)
}
}
return
}
onAction?(action)
}
}
@@ -1,7 +1,372 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
" | " : {
},
"." : {
},
"6-digit code" : {
},
"acknowledge that you agree to AFFiNE's" : {
},
"AFFiNE Cloud" : {
},
"and" : {
},
"Cancel Anytime" : {
},
"Continue with Email" : {
},
"Email" : {
},
"Enter the code sent to" : {
},
"Enter your email address" : {
},
"Enter your password" : {
},
"Everything," : {
},
"G" : {
},
"Get Started" : {
},
"Individual Plans" : {
},
"Or" : {
},
"Sign in" : {
},
"We'll use this to improve your experience and prioritize relevant features." : {
},
"Which best describes you?" : {
},
"Write, organize, and connect ideas with docs, whiteboards, and AI." : {
},
"AI" : {
},
"AFFINE AI" : {
},
"AFFiNE home is still loading." : {
},
"AFFiNE is still loading. Please try again in a moment." : {
},
"AFFiNE is still loading. Please wait a moment and try again." : {
},
"AI-powered organization and insights" : {
},
"Access AFFiNE on Mac, Windows, Linux, Web, iPhone, and Android" : {
},
"Access your workspace on Mac, Windows, Linux, Web, iPhone, and Android" : {
},
"Bigger Picture" : {
},
"Capture notes, meetings, and plans in one focused workspace." : {
},
"Chat with your documents and knowledge" : {
},
"Checking sign-in method..." : {
},
"Clear Docs" : {
},
"Connect ideas, plans, and knowledge visually." : {
},
"Connect to a Self-Hosted Instance" : {
},
"Continue" : {
},
"Continue anywhere. Work across\niPhone, iPad, and desktop." : {
},
"Continue free" : {
},
"Continue with Apple" : {
},
"Continue with Google" : {
},
"Create here" : {
},
"Docs" : {
},
"Educator" : {
},
"Every Device" : {
},
"Everything stays up to date, wherever you work" : {
},
"Enter a valid email address." : {
},
"Enter your password." : {
},
"Enter the 6-digit sign-in code." : {
},
"For people who want to create, organize faster with AI." : {
},
"For people who want their workspace available everywhere." : {
},
"Finishing sign-in..." : {
},
"Generate articles, notes, and content in seconds" : {
},
"Get More Done with " : {
},
"Keep your knowledge available everywhere." : {
},
"LITE" : {
},
"Loading price" : {
},
"Logging in..." : {
},
"Missing required information" : {
},
"Multiple Views" : {
},
"Next" : {
},
"No active purchases were found to restore." : {
},
"One Idea, " : {
},
"Onboarding" : {
},
"Opening self-hosted sign-in..." : {
},
"Other" : {
},
"Paywall" : {
},
"Pick up where you left off, anytime" : {
},
"Plans" : {
},
"Privacy Policy" : {
},
"Product" : {
},
"Professional" : {
},
"Pro" : {
},
"Restore" : {
},
"Resend code" : {
},
"See pro benefits" : {
},
"See the " : {
},
"Sending a new code..." : {
},
"Sign in Success" : {
},
"Sign-in was cancelled." : {
},
"Signing in..." : {
},
"Start AFFiNE without an account" : {
},
"Stay synced across desktop, web, and mobile devices." : {
},
"Student" : {
},
"Subscriptions auto-renew until canceled." : {
},
"Subscription Terms" : {
},
"Tasks" : {
},
"Terms of Conditions" : {
},
"Terms of Use" : {
},
"This email is not available for sign-in." : {
},
"Turn Ideas into " : {
},
"Unable to check available sign-in methods." : {
},
"Unable to complete OAuth sign-in." : {
},
"Unable to complete sign-in." : {
},
"Unable to determine subscription status." : {
},
"Unable to load subscription options right now." : {
},
"Unable to load the selected plan." : {
},
"Unable to open OAuth sign-in." : {
},
"Unable to present sign-in." : {
},
"Unable to read appearance setting." : {
},
"Unable to sign in. Please try again." : {
},
"Unable to start OAuth sign-in." : {
},
"Use email code instead" : {
},
"Verifying sign-in code..." : {
},
"Views" : {
},
"Waiting for Apple sign-in..." : {
},
"Waiting for Google sign-in..." : {
},
"OK" : {
},
"We sent a new sign-in code to %@." : {
},
"We sent a sign-in code to %@." : {
},
"Write, summarize, and structure ideas in seconds." : {
},
"Your Work, " : {
},
"Your purchase is still being processed. Please try again in a moment." : {
},
"/month" : {
},
"/mo, billed annually" : {
},
"/year" : {
},
"BEST FOR YOU" : {
},
"Secure cloud backup for your content" : {
},
"Sync docs and boards across all devices" : {
},
"Upload files larger than 10 MB" : {
},
"Rewrite, improve, and translate your writing" : {
}
},
"version" : "1.0"
}
}
@@ -0,0 +1,178 @@
import AffinePaywall
import AffineResources
import SwiftUI
struct AppPaywallCard: View {
let plan: AppPaywallPlan
let priceInfo: NativePaywallPriceInfo?
let layout: AppPaywallLayout
let palette: AppPaywallPalette
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(LocalizedStringKey(plan.headerName))
.font(.system(size: layout.cardTitleFontSize, weight: .black))
.foregroundStyle(palette.primaryText)
.padding(.bottom, layout.sectionSpacing * 0.8)
if let priceInfo {
HStack(alignment: .lastTextBaseline, spacing: 5) {
Text(priceInfo.value)
.font(.system(size: layout.priceFontSize, weight: .black))
.foregroundStyle(palette.primaryText)
Text(LocalizedStringKey(priceInfo.suffix))
.font(.system(size: layout.priceSuffixFontSize, weight: .bold))
.foregroundStyle(palette.primaryText)
}
} else {
Text("Loading price")
.font(.system(size: layout.priceFontSize * 0.55, weight: .bold))
.foregroundStyle(palette.secondaryText)
.frame(height: layout.priceFontSize)
}
Text(LocalizedStringKey(plan.description))
.font(.system(size: layout.descriptionFontSize, weight: .medium))
.foregroundStyle(palette.secondaryText)
.lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 7)
.padding(.bottom, layout.sectionSpacing)
Divider()
.overlay(AffineColors.layerBorder.color.opacity(0.55))
.padding(.bottom, layout.sectionSpacing)
VStack(alignment: .leading, spacing: layout.featureSpacing) {
ForEach(plan.features, id: \.self) { feature in
AppPaywallFeatureRow(text: feature, fontSize: layout.featureFontSize, palette: palette)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, layout.cardHorizontalPadding)
.padding(.top, layout.cardTopPadding)
.padding(.bottom, layout.cardBottomPadding)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(palette.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: palette.cardPrimaryShadow, radius: 22, x: 0, y: 10)
.shadow(color: palette.cardSecondaryShadow, radius: 16, x: 0, y: 5)
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(palette.cardBorder, lineWidth: 1.45)
}
.overlay(alignment: .topTrailing) {
if let badge = plan.badge {
Text(LocalizedStringKey(badge))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(AffineColors.layerPureWhite.color)
.padding(.horizontal, 16)
.padding(.vertical, 7)
.background(AffineColors.buttonPrimary.color)
.clipShape(Capsule())
.shadow(color: AffineColors.buttonPrimary.color.opacity(0.22), radius: 12, x: 0, y: 5)
.offset(x: -8, y: -15)
}
}
}
}
private struct AppPaywallFeatureRow: View {
let text: String
let fontSize: CGFloat
let palette: AppPaywallPalette
var body: some View {
HStack(alignment: .top, spacing: 11) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: max(13, fontSize - 0.5), weight: .bold))
.foregroundStyle(AffineColors.buttonPrimary.color)
.padding(.top, 2)
Text(LocalizedStringKey(text))
.font(.system(size: fontSize, weight: .medium))
.foregroundStyle(palette.primaryText)
.lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
}
}
}
struct AppPaywallFooterLinks: View {
let palette: AppPaywallPalette
var body: some View {
VStack(spacing: 4) {
Text("Cancel Anytime")
.font(.system(size: 16.5, weight: .medium))
.foregroundStyle(palette.primaryText)
Text("Subscriptions auto-renew until canceled.")
.font(.system(size: 12.5, weight: .regular))
.foregroundStyle(palette.secondaryText)
}
}
}
struct AppPaywallLegalLinks: View {
let palette: AppPaywallPalette
let onOpenTerms: () -> Void
let onOpenPrivacy: () -> Void
let onOpenSubscriptionTerms: () -> Void
let onRestore: () -> Void
var body: some View {
ViewThatFits {
HStack(spacing: 0) {
legalButton(title: "Terms of Use", action: onOpenTerms)
separator
legalButton(title: "Privacy Policy", action: onOpenPrivacy)
separator
legalButton(title: "Subscription Terms", action: onOpenSubscriptionTerms)
separator
legalButton(title: "Restore", action: onRestore)
}
VStack(spacing: 6) {
HStack(spacing: 0) {
legalButton(title: "Terms of Use", action: onOpenTerms)
separator
legalButton(title: "Privacy Policy", action: onOpenPrivacy)
}
HStack(spacing: 0) {
legalButton(title: "Subscription Terms", action: onOpenSubscriptionTerms)
separator
legalButton(title: "Restore", action: onRestore)
}
}
}
.font(.system(size: 12.5, weight: .medium))
.foregroundStyle(palette.secondaryText)
}
private var separator: some View {
Text(" | ")
.foregroundStyle(palette.secondaryText)
}
private func legalButton(title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(LocalizedStringKey(title))
.foregroundStyle(palette.secondaryText)
}
.buttonStyle(.plain)
}
}
#Preview {
AppPaywallRootView(
bridge: NativePaywallBridge(initialPlan: .pro),
initialPlan: .pro,
onPurchase: { _ in },
onRestorePurchases: {},
onClose: {}
)
}
@@ -0,0 +1,188 @@
import AffineResources
import SwiftUI
struct AppPaywallPalette {
let colorScheme: ColorScheme
private var isDark: Bool {
colorScheme == .dark
}
var backgroundOverlay: Color {
isDark ? Color.black.opacity(0.18) : Color.clear
}
var gridOpacity: Double {
isDark ? 0.22 : 1
}
var primaryText: Color {
AffineColors.textPrimary.color
}
var secondaryText: Color {
AffineColors.textSecondary.color
}
var cardBackground: Color {
isDark ? AffineColors.layerBackgroundSecondary.color.opacity(0.96) : AffineColors.layerPureWhite.color
}
var cardBorder: Color {
isDark ? AffineColors.buttonPrimary.color.opacity(0.62) : AffineColors.buttonPrimary.color.opacity(0.98)
}
var cardPrimaryShadow: Color {
isDark ? Color.black.opacity(0.34) : AffineColors.buttonPrimary.color.opacity(0.12)
}
var cardSecondaryShadow: Color {
isDark ? Color.black.opacity(0.26) : Color.black.opacity(0.06)
}
var carouselShadow: Color {
Color.black
}
var closeButtonBackground: Color {
isDark ? Color.white.opacity(0.08) : Color.white.opacity(0.72)
}
var closeButtonForeground: Color {
AffineColors.textSecondary.color
}
var inactiveDot: Color {
AffineColors.buttonPrimary.color.opacity(isDark ? 0.26 : 0.18)
}
}
struct AppPaywallLayout {
let size: CGSize
let safeAreaInsets: EdgeInsets
private var isLandscape: Bool {
size.width > size.height
}
private var isCompactLandscape: Bool {
isLandscape && min(size.width, size.height) < 430
}
var pageMinHeight: CGFloat {
max(size.height, 640)
}
var headerTopPadding: CGFloat {
isCompactLandscape ? max(safeAreaInsets.top + 4, 8) : 14
}
var horizontalPadding: CGFloat {
max(isLandscape ? 24 : 28, max(safeAreaInsets.leading, safeAreaInsets.trailing) + 18)
}
var headerHorizontalPadding: CGFloat {
max(18, max(safeAreaInsets.leading, safeAreaInsets.trailing) + 14)
}
var titleTopSpacing: CGFloat {
isCompactLandscape ? 8 : 18
}
var titleBottomSpacing: CGFloat {
isCompactLandscape ? 8 : 10
}
var titleFontSize: CGFloat {
isCompactLandscape ? 23 : 30
}
var carouselHeight: CGFloat {
if isCompactLandscape {
return min(max(size.height * 0.54, 250), 300)
}
return isLandscape ? min(max(size.height * 0.58, 330), 430) : 494
}
var cardSpacing: CGFloat {
isCompactLandscape ? 6 : 8
}
func cardWidth(for availableWidth: CGFloat) -> CGFloat {
if isCompactLandscape {
return min(max(availableWidth * 0.38, 218), 272)
}
if isLandscape {
return min(max(availableWidth * 0.34, 258), 320)
}
return min(max(availableWidth - 116, 244), 288)
}
var cardTitleFontSize: CGFloat {
isCompactLandscape ? 22 : 28
}
var priceFontSize: CGFloat {
isCompactLandscape ? 24 : 30
}
var priceSuffixFontSize: CGFloat {
isCompactLandscape ? 13 : 16
}
var descriptionFontSize: CGFloat {
isCompactLandscape ? 13 : 15.5
}
var featureFontSize: CGFloat {
isCompactLandscape ? 12.5 : 15.5
}
var featureSpacing: CGFloat {
isCompactLandscape ? 9 : 17
}
var cardHorizontalPadding: CGFloat {
isCompactLandscape ? 17 : 23
}
var cardTopPadding: CGFloat {
isCompactLandscape ? 16 : 22
}
var cardBottomPadding: CGFloat {
isCompactLandscape ? 14 : 20
}
var sectionSpacing: CGFloat {
isCompactLandscape ? 10 : 20
}
var buttonHeight: CGFloat {
isCompactLandscape ? 50 : 58
}
var buttonFontSize: CGFloat {
isCompactLandscape ? 16 : 18
}
var dotsTopPadding: CGFloat {
isCompactLandscape ? 8 : 10
}
var footerTopPadding: CGFloat {
isCompactLandscape ? 6 : 8
}
var buttonTopPadding: CGFloat {
isCompactLandscape ? 8 : 10
}
var legalTopPadding: CGFloat {
isCompactLandscape ? 8 : 10
}
var legalBottomPadding: CGFloat {
max(safeAreaInsets.bottom + (isCompactLandscape ? 6 : 8), isCompactLandscape ? 8 : 16)
}
}
@@ -0,0 +1,321 @@
import AffinePaywall
import AffineResources
import SwiftUI
struct AppPaywallRootView: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var bridge: NativePaywallBridge
let initialPlan: AppPaywallPlan
let onPurchase: (AppPaywallPlan) -> Void
let onRestorePurchases: () -> Void
let onClose: () -> Void
private var palette: AppPaywallPalette {
AppPaywallPalette(colorScheme: colorScheme)
}
var body: some View {
GeometryReader { geometry in
let layout = AppPaywallLayout(
size: geometry.size,
safeAreaInsets: geometry.safeAreaInsets
)
ZStack {
OnboardingBackground(isIntroPage: false)
palette.backgroundOverlay
.ignoresSafeArea()
ScrollView(.vertical, showsIndicators: false) {
AppPaywallCarouselPage(
bridge: bridge,
initialPlan: initialPlan,
layout: layout,
palette: palette,
onPurchase: onPurchase,
onRestorePurchases: onRestorePurchases,
onClose: onClose
)
.frame(minHeight: layout.pageMinHeight, alignment: .top)
.frame(width: geometry.size.width, alignment: .top)
}
.scrollBounceBehavior(.basedOnSize)
IntroGridOverlay()
.opacity(palette.gridOpacity)
.ignoresSafeArea()
.allowsHitTesting(false)
.zIndex(999)
}
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
.foregroundStyle(palette.primaryText)
}
.alert(
"Paywall",
isPresented: Binding(
get: { bridge.errorMessage != nil },
set: { isPresented in
if !isPresented {
bridge.errorMessage = nil
}
}
)
) {
Button("OK", role: .cancel) {}
} message: {
Text(bridge.errorMessage ?? "")
}
}
}
struct AppPaywallCarouselPage: View {
@Environment(\.openURL) private var openURL
@ObservedObject var bridge: NativePaywallBridge
let initialPlan: AppPaywallPlan
let layout: AppPaywallLayout
let palette: AppPaywallPalette
let onPurchase: (AppPaywallPlan) -> Void
let onRestorePurchases: () -> Void
let onClose: () -> Void
private let plans: [AppPaywallPlan] = [.lite, .pro, .ai]
private let visibleSlots = [-2, -1, 0, 1, 2]
private let settleAnimation = Animation.spring(response: 0.32, dampingFraction: 0.9)
private let settleDuration = 0.24
@State private var selectedPlan: AppPaywallPlan = .pro
@State private var currentPlanIndex = 1
@State private var settlingOffset: CGFloat = 0
@State private var isSettling = false
@GestureState private var dragTranslation: CGFloat = 0
init(
bridge: NativePaywallBridge,
initialPlan: AppPaywallPlan,
layout: AppPaywallLayout,
palette: AppPaywallPalette,
onPurchase: @escaping (AppPaywallPlan) -> Void,
onRestorePurchases: @escaping () -> Void,
onClose: @escaping () -> Void
) {
self.bridge = bridge
self.initialPlan = initialPlan
self.layout = layout
self.palette = palette
self.onPurchase = onPurchase
self.onRestorePurchases = onRestorePurchases
self.onClose = onClose
let planOrder: [AppPaywallPlan] = [.lite, .pro, .ai]
let initialIndex = planOrder.firstIndex(of: initialPlan) ?? 1
_selectedPlan = State(initialValue: planOrder[initialIndex])
_currentPlanIndex = State(initialValue: initialIndex)
}
private var activePlanIndex: Int {
currentPlanIndex
}
var body: some View {
VStack(spacing: 0) {
paywallHeader
.padding(.top, layout.headerTopPadding)
.padding(.horizontal, layout.headerHorizontalPadding)
Spacer(minLength: layout.titleTopSpacing)
Text("Individual Plans")
.font(.system(size: layout.titleFontSize, weight: .black))
.foregroundStyle(palette.primaryText)
Spacer(minLength: layout.titleBottomSpacing)
GeometryReader { geometry in
let cardWidth = layout.cardWidth(for: geometry.size.width)
let cardSpacing = layout.cardSpacing
let step = cardWidth + cardSpacing
let totalOffset = settlingOffset + dragTranslation
let normalizedOffset = totalOffset / step
ZStack {
ForEach(visibleSlots, id: \.self) { relativeSlot in
let position = CGFloat(relativeSlot) + normalizedOffset
let distance = abs(position)
let clampedDistance = min(distance, 2)
let horizontalDirection: CGFloat = position > 0 ? 1 : (position < 0 ? -1 : 0)
let scale = max(0.94, 1 - clampedDistance * 0.038)
let opacity = max(0.8, 1 - clampedDistance * 0.11)
let sideSpread = clampedDistance * 2
let verticalOffset = clampedDistance * (layout.carouselHeight < 320 ? 9 : 16)
let rotation = Double(position * 6)
let shadowOpacity = Double(max(0.09, 0.2 - clampedDistance * 0.05))
let shadowRadius = max(18, 30 - clampedDistance * 5)
let shadowYOffset = max(10, 18 - clampedDistance * 2.5)
AppPaywallCard(
plan: plan(for: relativeSlot),
priceInfo: bridge.priceInfo(for: plan(for: relativeSlot).planKind),
layout: layout,
palette: palette
)
.frame(width: cardWidth, height: geometry.size.height)
.scaleEffect(scale)
.rotation3DEffect(
.degrees(rotation),
axis: (x: 0, y: 1, z: 0),
perspective: 0.82
)
.opacity(opacity)
.offset(
x: position * step + horizontalDirection * sideSpread,
y: verticalOffset
)
.shadow(color: palette.carouselShadow.opacity(shadowOpacity), radius: shadowRadius, x: 0, y: shadowYOffset)
.zIndex(10 - distance)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.allowsHitTesting(!isSettling && !bridge.isProcessing)
.highPriorityGesture(carouselDragGesture(step: step))
}
.frame(height: layout.carouselHeight)
HStack(spacing: 9) {
ForEach(0..<plans.count, id: \.self) { index in
Circle()
.fill(index == activePlanIndex ? AffineColors.buttonPrimary.color : palette.inactiveDot)
.frame(width: 9, height: 9)
}
}
.padding(.top, layout.dotsTopPadding)
AppPaywallFooterLinks(palette: palette)
.padding(.top, layout.footerTopPadding)
.padding(.horizontal, layout.horizontalPadding)
PrimaryButton(
title: selectedPlan.buttonTitle,
isLoading: bridge.isLoading || bridge.isProcessing,
isEnabled: bridge.isReady,
fontSize: layout.buttonFontSize,
height: layout.buttonHeight
) {
triggerPaywallHaptic()
onPurchase(selectedPlan)
}
.padding(.horizontal, layout.horizontalPadding)
.padding(.top, layout.buttonTopPadding)
AppPaywallLegalLinks(
palette: palette,
onOpenTerms: { openLegalURL("https://affine.pro/terms") },
onOpenPrivacy: { openLegalURL("https://affine.pro/privacy") },
onOpenSubscriptionTerms: { openLegalURL("https://affine.pro/terms/#subscription") },
onRestore: onRestorePurchases
)
.padding(.top, layout.legalTopPadding)
.padding(.bottom, layout.legalBottomPadding)
.padding(.horizontal, layout.horizontalPadding)
}
.frame(maxWidth: .infinity, minHeight: layout.pageMinHeight, alignment: .top)
.onAppear {
currentPlanIndex = selectedIndex(for: initialPlan)
selectedPlan = plans[currentPlanIndex]
bridge.selectPlan(selectedPlan.planKind)
settlingOffset = 0
}
.onChange(of: selectedPlan) { plan in
bridge.selectPlan(plan.planKind)
}
}
private var paywallHeader: some View {
HStack {
Spacer()
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(palette.closeButtonForeground)
.frame(width: 32, height: 32)
.background(palette.closeButtonBackground)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(.plain)
}
}
private func carouselDragGesture(step: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 12)
.updating($dragTranslation) { value, state, _ in
state = value.translation.width
}
.onEnded { value in
guard !isSettling else { return }
settlingOffset = value.translation.width
let threshold = step * 0.18
let projectedOffset = value.predictedEndTranslation.width
if projectedOffset < -threshold {
settleCarousel(step: step, direction: 1)
} else if projectedOffset > threshold {
settleCarousel(step: step, direction: -1)
} else {
withAnimation(settleAnimation) {
settlingOffset = 0
}
}
}
}
private func settleCarousel(step: CGFloat, direction: Int) {
isSettling = true
withAnimation(settleAnimation) {
settlingOffset = direction > 0 ? -step : step
}
DispatchQueue.main.asyncAfter(deadline: .now() + settleDuration) {
let nextIndex = wrappedIndex(currentPlanIndex + direction)
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
currentPlanIndex = nextIndex
selectedPlan = plans[nextIndex]
settlingOffset = 0
}
isSettling = false
}
}
private func selectedIndex(for plan: AppPaywallPlan) -> Int {
plans.firstIndex(of: plan) ?? 1
}
private func wrappedIndex(_ index: Int) -> Int {
let count = plans.count
let remainder = index % count
return remainder >= 0 ? remainder : remainder + count
}
private func plan(for relativeSlot: Int) -> AppPaywallPlan {
plans[wrappedIndex(currentPlanIndex + relativeSlot)]
}
private func openLegalURL(_ string: String) {
guard let url = URL(string: string) else { return }
openURL(url)
}
private func triggerPaywallHaptic() {
let generator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
generator.impactOccurred()
}
}
@@ -0,0 +1,239 @@
import AffinePaywall
import AffineResources
import SwiftUI
import UIKit
import WebKit
final class AppPaywallViewController: UIViewController {
var initialPlan: AppPaywallPlan = .pro
weak var bindWebView: WKWebView?
var onClose: (() -> Void)?
var onPurchaseCompleted: (() -> Void)?
private lazy var bridge = NativePaywallBridge(initialPlan: initialPlan.planKind)
private var hostingController: UIHostingController<AppPaywallRootView>?
private var prepareTask: Task<Void, Never>?
private var actionTask: Task<Void, Never>?
private static var cachedUserInterfaceStyle: UIUserInterfaceStyle {
switch UserDefaults.standard.string(forKey: AffineThemeStorage.modeKey) {
case "dark":
return .dark
case "light":
return .light
default:
return .unspecified
}
}
override func viewDidLoad() {
super.viewDidLoad()
overrideUserInterfaceStyle = Self.cachedUserInterfaceStyle
updateBackgroundColor()
bridge.bind(webView: bindWebView)
let rootView = AppPaywallRootView(
bridge: bridge,
initialPlan: initialPlan,
onPurchase: { [weak self] plan in
self?.purchase(plan: plan)
},
onRestorePurchases: { [weak self] in
self?.restorePurchases()
},
onClose: { [weak self] in
self?.closePaywall()
}
)
let hostingController = UIHostingController(rootView: rootView)
hostingController.view.backgroundColor = .clear
self.hostingController = hostingController
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hostingController.didMove(toParent: self)
prepareTask = Task { @MainActor [weak self] in
await self?.prepareBridge()
}
}
deinit {
prepareTask?.cancel()
actionTask?.cancel()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else { return }
updateBackgroundColor()
}
private func updateBackgroundColor() {
view.backgroundColor = UIColor(named: "OnboardingBackground") ?? .systemBackground
}
@MainActor
private func prepareBridge() async {
do {
try await bridge.prepare()
} catch {
bridge.errorMessage = error.localizedDescription
}
}
private func purchase(plan: AppPaywallPlan) {
actionTask?.cancel()
actionTask = Task { @MainActor [weak self] in
guard let self else { return }
bridge.selectPlan(plan.planKind)
do {
let completed = try await bridge.purchaseSelectedPlan()
guard completed else { return }
guard try await hasActiveSubscription(for: plan) else {
bridge.errorMessage = String(localized: "Your purchase is still being processed. Please try again in a moment.")
return
}
dismiss(animated: true) { [weak self] in
self?.onPurchaseCompleted?()
}
} catch is CancellationError {
return
} catch {
bridge.errorMessage = error.localizedDescription
}
}
}
private func restorePurchases() {
actionTask?.cancel()
actionTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
try await bridge.restorePurchases()
guard try await hasActiveSubscription(for: initialPlan) else {
bridge.errorMessage = String(localized: "No active purchases were found to restore.")
return
}
dismiss(animated: true) { [weak self] in
self?.onPurchaseCompleted?()
}
} catch is CancellationError {
return
} catch {
bridge.errorMessage = error.localizedDescription
}
}
}
private func hasActiveSubscription(for plan: AppPaywallPlan) async throws -> Bool {
guard let webView = bindWebView else {
throw NSError(
domain: "AppPaywallViewController",
code: -1,
userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing required information")]
)
}
switch plan {
case .ai:
return try await PaywallAuthGuard.hasAISubscription(in: webView)
case .pro, .lite:
return try await PaywallAuthGuard.hasProSubscription(in: webView)
}
}
private func closePaywall() {
actionTask?.cancel()
dismiss(animated: true) { [weak self] in
self?.onClose?()
}
}
}
enum AppPaywallPlan: String, CaseIterable {
case pro
case lite
case ai
var planKind: NativePaywallPlanKind {
switch self {
case .pro:
.pro
case .lite:
.lite
case .ai:
.ai
}
}
var headerName: String {
switch self {
case .pro: "Pro"
case .lite: "LITE"
case .ai: "AFFINE AI"
}
}
var badge: String? {
switch self {
case .pro: "BEST FOR YOU"
case .lite, .ai: nil
}
}
var description: String {
switch self {
case .pro: "Keep your knowledge available everywhere."
case .lite: "For people who want their workspace available everywhere."
case .ai: "For people who want to create, organize faster with AI."
}
}
var buttonTitle: String {
"Continue"
}
var features: [String] {
switch self {
case .pro:
[
"Upload files larger than 10 MB",
"Sync docs and boards across all devices",
"Access your workspace on Mac, Windows, Linux, Web, iPhone, and Android",
"Secure cloud backup for your content",
"Everything stays up to date, wherever you work",
]
case .lite:
[
"Sync docs and boards across all devices",
"Access AFFiNE on Mac, Windows, Linux, Web, iPhone, and Android",
"Upload files larger than 10 MB",
"Secure cloud backup for your content",
"Pick up where you left off, anytime",
]
case .ai:
[
"Generate articles, notes, and content in seconds",
"Rewrite, improve, and translate your writing",
"Turn ideas into visuals, mind maps, and presentations",
"Chat with your documents and knowledge",
"AI-powered organization and insights",
]
}
}
}
@@ -0,0 +1,178 @@
import AffineResources
import SwiftUI
struct RolePage: View {
@Binding var selectedRole: OnboardingRole?
private let roles = OnboardingRole.allCases
var body: some View {
VStack(spacing: 24) {
Color.clear
.frame(height: 18)
VStack(spacing: 18) {
Text("Which best describes you?")
.font(.system(size: 26, weight: .bold, design: .rounded))
.multilineTextAlignment(.center)
Text("We'll use this to improve your experience and prioritize relevant features.")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(AffineColors.textSecondary.color)
.multilineTextAlignment(.center)
}
VStack(spacing: 12) {
ForEach(roles, id: \.self) { role in
RoleOption(
role: role,
isSelected: selectedRole == role
) {
selectedRole = role
}
}
}
Spacer()
}
}
}
struct FeaturePage: View {
let feature: OnboardingFeature
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 10)
VStack(spacing: 12) {
featureTitle
Text(LocalizedStringKey(feature.subtitle))
.font(.system(size: 16, weight: .medium))
.foregroundStyle(AffineColors.textSecondary.color)
.multilineTextAlignment(.center)
.lineSpacing(3)
.padding(.horizontal, 12)
}
Spacer(minLength: 18)
FeatureArtwork(feature: feature)
.frame(maxWidth: .infinity)
.frame(height: 360)
Spacer(minLength: 10)
}
}
private var featureTitle: some View {
feature.titleSegments.reduce(Text("")) { partial, segment in
partial
+ Text(LocalizedStringKey(segment.text))
.foregroundColor(segment.isHighlighted ? AffineColors.buttonPrimary.color : AffineColors.textPrimary.color)
}
.font(.system(size: 30, weight: .bold))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 8)
}
}
private struct RoleOption: View {
let role: OnboardingRole
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
Image(role.assetName)
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 38, height: 38)
.foregroundStyle(AffineColors.textPrimary.color)
Text(LocalizedStringKey(role.title))
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(AffineColors.textPrimary.color)
Spacer()
}
.padding(.horizontal, 20)
.frame(height: 84)
.background(isSelected ? AffineColors.buttonPrimary.color.opacity(0.12) : AffineColors.layerPureWhite.color)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(
isSelected ? AffineColors.buttonPrimary.color : AffineColors.layerBorder.color.opacity(0.7),
lineWidth: isSelected ? 2 : 1
)
}
.shadow(color: .black.opacity(isSelected ? 0.05 : 0.04), radius: 12, x: 0, y: 6)
}
.buttonStyle(.plain)
}
}
private struct FeatureArtwork: View {
let feature: OnboardingFeature
var body: some View {
Image(feature.assetName)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 4)
.accessibilityHidden(true)
}
}
struct PrimaryButton: View {
let title: String
var isLoading = false
var isEnabled = true
var fontSize: CGFloat = 16
var height: CGFloat = 54
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 10) {
if isLoading {
ProgressView()
.tint(AffineColors.layerPureWhite.color)
}
Text(LocalizedStringKey(title))
.font(.system(size: fontSize, weight: .bold))
}
.foregroundStyle(AffineColors.layerPureWhite.color)
.frame(maxWidth: .infinity)
.frame(height: height)
.background(isEnabled ? AffineColors.buttonPrimary.color : AffineColors.textPlaceholder.color)
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
}
.buttonStyle(.plain)
.disabled(isLoading || !isEnabled)
.opacity(isLoading ? 0.8 : (isEnabled ? 1 : 0.72))
}
}
struct PageDots: View {
let count: Int
let selectedIndex: Int
private let dotHeight: CGFloat = 6
private let dotSpacing: CGFloat = 12
var body: some View {
HStack(spacing: dotSpacing) {
ForEach(0..<count, id: \.self) { index in
Capsule()
.fill(index == selectedIndex ? AffineColors.buttonPrimary.color : AffineColors.buttonPrimary.color.opacity(0.18))
.frame(height: dotHeight)
.frame(maxWidth: .infinity)
}
}
.frame(height: dotHeight)
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: selectedIndex)
}
}
#Preview {
OnboardingRootView(onCompleteOnboarding: {})
}
@@ -0,0 +1,19 @@
import Foundation
enum OnboardingFlag {
private static let completedKey = "com.affine.onboarding.completed"
static var isCompleted: Bool {
UserDefaults.standard.bool(forKey: completedKey)
}
static func markCompleted() {
UserDefaults.standard.set(true, forKey: completedKey)
}
#if DEBUG
static func reset() {
UserDefaults.standard.removeObject(forKey: completedKey)
}
#endif
}
@@ -0,0 +1,113 @@
import AffineResources
import SwiftUI
struct OnboardingBackground: View {
let isIntroPage: Bool
var body: some View {
Group {
if isIntroPage {
Color("OnboardingIntroBackground")
} else {
Color("OnboardingBackground")
}
}
.ignoresSafeArea()
}
}
struct IntroPage: View {
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 18)
VStack(spacing: 16) {
VStack(spacing: 6) {
titleView
Text("Write, organize, and connect ideas with docs, whiteboards, and AI.")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(AffineColors.textSecondary.color)
.multilineTextAlignment(.center)
.lineSpacing(4)
.padding(.horizontal, 22)
}
IntroHeroArtwork()
.frame(height: 420)
.padding(.horizontal, 10)
}
Spacer(minLength: 0)
}
}
private var titleView: some View {
(
Text("Everything,")
.foregroundColor(AffineColors.buttonPrimary.color)
.italic()
+ Text(" in One\nWorkspace")
.foregroundColor(AffineColors.textPrimary.color)
)
.font(.system(size: 31, weight: .bold))
.multilineTextAlignment(.center)
}
}
struct IntroFooter: View {
let onGetStarted: () -> Void
var body: some View {
VStack(spacing: 16) {
Button(action: onGetStarted) {
HStack(spacing: 10) {
Text("Get Started")
.font(.system(size: 18, weight: .bold))
Image(systemName: "arrow.right")
.font(.system(size: 16, weight: .bold))
}
.foregroundStyle(AffineColors.layerPureWhite.color)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(AffineColors.buttonPrimary.color)
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
}
.buttonStyle(.plain)
}
}
}
private struct IntroHeroArtwork: View {
var body: some View {
Image("OnboardingIntroReference")
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.horizontal, 4)
.accessibilityHidden(true)
}
}
struct IntroGridOverlay: View {
private let spacing: CGFloat = 12
var body: some View {
GeometryReader { geometry in
let drawWidth = geometry.size.width
let drawHeight = geometry.size.height
Path { path in
stride(from: 0, through: drawWidth, by: spacing).forEach { x in
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: drawHeight))
}
stride(from: 0, through: drawHeight, by: spacing).forEach { y in
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: drawWidth, y: y))
}
}
.stroke(Color.red.opacity(0.4), lineWidth: 0.4)
.frame(width: drawWidth, height: drawHeight, alignment: .topLeading)
}
.ignoresSafeArea()
}
}
@@ -0,0 +1,345 @@
import AffineResources
import SwiftUI
import UIKit
struct OnboardingRootView: View {
let onCompleteOnboarding: () -> Void
@State private var pageIndex = 0
@State private var selectedRole: OnboardingRole?
private let pages = OnboardingPage.all
private var isIntroPage: Bool {
pages[pageIndex].isIntro
}
private var isRolePage: Bool {
if case .role = pages[pageIndex] { return true }
return false
}
private var shouldShowHeader: Bool {
!isIntroPage
}
private var isNextEnabled: Bool {
!isRolePage || selectedRole != nil
}
private var shouldShowContinueLabel: Bool {
pageIndex == pages.count - 1
}
private var progressPageCount: Int {
pages.filter(\.showsProgress).count
}
private var progressIndex: Int {
let visibleProgressCount = pages.prefix(pageIndex + 1).filter(\.showsProgress).count
return max(visibleProgressCount - 1, 0)
}
var body: some View {
GeometryReader { geometry in
let layout = OnboardingLayout(size: geometry.size, safeAreaInsets: geometry.safeAreaInsets)
ZStack {
Color.clear
.ignoresSafeArea()
OnboardingBackground(isIntroPage: isIntroPage)
onboardingContent(layout: layout)
#if DEBUG
if isIntroPage {
IntroGridOverlay()
.ignoresSafeArea()
.allowsHitTesting(false)
.zIndex(999)
}
#endif
}
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.foregroundStyle(AffineColors.textPrimary.color)
}
@ViewBuilder
private func onboardingContent(layout: OnboardingLayout) -> some View {
if layout.usesScrollableContainer {
ScrollView(.vertical, showsIndicators: false) {
onboardingStack(layout: layout)
.frame(maxWidth: layout.maxContentWidth)
.frame(maxWidth: .infinity)
}
} else {
onboardingStack(layout: layout)
.frame(maxWidth: layout.maxContentWidth)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func onboardingStack(layout: OnboardingLayout) -> some View {
VStack(spacing: 0) {
VStack(spacing: 0) {
if shouldShowHeader {
header
}
pageContainer
.frame(height: layout.pageContainerHeight(for: pages[pageIndex]))
}
.offset(y: isIntroPage ? 0 : -30)
footer
.offset(y: 30)
}
}
private var header: some View {
let sideInset: CGFloat = 12
let backButtonSize: CGFloat = 40
let pageDotsHorizontalInset: CGFloat = 65
return ZStack(alignment: .leading) {
PageDots(count: progressPageCount, selectedIndex: progressIndex)
.frame(maxWidth: .infinity)
.padding(.horizontal, pageDotsHorizontalInset)
Button {
goBack()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.frame(width: backButtonSize, height: backButtonSize)
.foregroundStyle(AffineColors.textSecondary.color)
}
.padding(.leading, sideInset)
.opacity(pageIndex > 0 ? 1 : 0)
.disabled(pageIndex == 0)
}
.frame(height: 44)
}
private var pageContainer: some View {
pageView(for: pages[pageIndex])
.padding(.horizontal, horizontalPadding(for: pages[pageIndex]))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(pageIndex)
.transition(.opacity)
.animation(.spring(response: 0.30, dampingFraction: 0.88), value: pageIndex)
}
@ViewBuilder
private func pageView(for page: OnboardingPage) -> some View {
switch page {
case .intro:
IntroPage()
case .role:
RolePage(selectedRole: $selectedRole)
case let .feature(feature):
FeaturePage(feature: feature)
}
}
private var footer: some View {
VStack(spacing: 10) {
if isIntroPage {
IntroFooter {
triggerOnboardingHaptic()
goNext()
}
} else {
PrimaryButton(
title: shouldShowContinueLabel ? "Continue" : "Next",
isEnabled: isNextEnabled,
fontSize: 18
) {
triggerOnboardingHaptic()
goNext()
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, isIntroPage ? 22 : 10)
}
private func goNext() {
guard isNextEnabled else { return }
guard pageIndex < pages.count - 1 else {
onCompleteOnboarding()
return
}
pageIndex += 1
}
private func goBack() {
guard pageIndex > 0 else { return }
pageIndex -= 1
}
private func horizontalPadding(for page: OnboardingPage) -> CGFloat {
if page.isIntro {
return 0
}
return 20
}
private func triggerOnboardingHaptic() {
let generator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
generator.impactOccurred()
}
}
private struct OnboardingLayout {
let size: CGSize
let safeAreaInsets: EdgeInsets
private var isLandscape: Bool {
size.width > size.height
}
private var isPadLike: Bool {
min(size.width, size.height) >= 700
}
private var isCompactHeight: Bool {
size.height < 560
}
private var horizontalSafeArea: CGFloat {
safeAreaInsets.leading + safeAreaInsets.trailing
}
var usesScrollableContainer: Bool {
isLandscape && !isPadLike && isCompactHeight
}
var maxContentWidth: CGFloat {
let availableWidth = max(size.width - horizontalSafeArea, 320)
if isPadLike {
return min(availableWidth, 620)
}
if usesScrollableContainer {
return min(availableWidth, 520)
}
return min(availableWidth, 430)
}
func pageContainerHeight(for page: OnboardingPage) -> CGFloat {
if usesScrollableContainer {
return compactPageHeight(for: page)
}
let reservedHeight: CGFloat = page.isIntro ? 72 : 116
let availableHeight = size.height - safeAreaInsets.top - safeAreaInsets.bottom - reservedHeight
return max(360, availableHeight)
}
private func compactPageHeight(for page: OnboardingPage) -> CGFloat {
switch page {
case .intro:
return 620
case .role:
return 560
case .feature:
return 540
}
}
}
private enum OnboardingPage {
case intro
case role
case feature(OnboardingFeature)
static let all: [OnboardingPage] = [
.intro,
.role,
.feature(.clearDocs),
.feature(.biggerPicture),
.feature(.multipleViews),
.feature(.everyDevice),
.feature(.ai),
]
var isIntro: Bool {
if case .intro = self { return true }
return false
}
var showsProgress: Bool {
switch self {
case .role, .feature:
return true
case .intro:
return false
}
}
}
enum OnboardingRole: String, CaseIterable {
case student = "Student"
case educator = "Educator"
case professional = "Professional"
case other = "Other"
var title: String {
rawValue
}
var assetName: String {
switch self {
case .student: "OnboardingRoleStudent"
case .educator: "OnboardingRoleEducator"
case .professional: "OnboardingRoleProfessional"
case .other: "OnboardingRoleOther"
}
}
}
enum OnboardingFeature: CaseIterable {
case clearDocs
case biggerPicture
case multipleViews
case everyDevice
case ai
var titleSegments: [(text: String, isHighlighted: Bool)] {
switch self {
case .clearDocs:
[("Turn Ideas into ", false), ("Clear Docs", true)]
case .biggerPicture:
[("See the ", false), ("Bigger Picture", true)]
case .multipleViews:
[("One Idea, ", false), ("Multiple Views", true)]
case .everyDevice:
[("Your Work, ", false), ("Every Device", true)]
case .ai:
[("Get More Done with ", false), ("AI", true)]
}
}
var subtitle: String {
switch self {
case .clearDocs: "Capture notes, meetings, and plans in one focused workspace."
case .biggerPicture: "Connect ideas, plans, and knowledge visually."
case .multipleViews: "Bring notes, whiteboards, and projects together in one workspace."
case .everyDevice: "Stay synced across desktop, web, and mobile devices."
case .ai: "Write, summarize, and structure ideas in seconds."
}
}
var assetName: String {
switch self {
case .clearDocs: "OnboardingFeatureClearDocs"
case .biggerPicture: "OnboardingFeatureBiggerPicture"
case .multipleViews: "OnboardingFeatureMultipleViews"
case .everyDevice: "OnboardingFeatureEveryDevice"
case .ai: "OnboardingFeatureAI"
}
}
}
@@ -0,0 +1,30 @@
import SwiftUI
import UIKit
final class OnboardingViewController: UIViewController {
var onCompleteOnboarding: (() -> Void)?
private var hostingController: UIHostingController<OnboardingRootView>?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(named: "OnboardingIntroBackground") ?? .systemBackground
let rootView = OnboardingRootView(onCompleteOnboarding: { [weak self] in
self?.onCompleteOnboarding?()
})
let hostingController = UIHostingController(rootView: rootView)
self.hostingController = hostingController
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hostingController.didMove(toParent: self)
}
}
@@ -5,6 +5,10 @@ protocol AffineThemeConfigurable: AnyObject {
var appThemeUserInterfaceStyle: UIUserInterfaceStyle { get set }
}
enum AffineThemeStorage {
static let modeKey = "affine.theme.mode"
}
private enum AffineThemeMode: String {
case dark
case light
@@ -27,6 +31,9 @@ public final class AffineThemePlugin: CAPPlugin, CAPBridgedPlugin {
init(associatedController: UIViewController?) {
controller = associatedController
super.init()
let cachedMode = AffineThemeMode(rawValue: UserDefaults.standard.string(forKey: AffineThemeStorage.modeKey) ?? "") ?? .system
(associatedController as? AffineThemeConfigurable)?.appThemeUserInterfaceStyle = cachedMode.userInterfaceStyle
}
weak var controller: UIViewController?
@@ -40,6 +47,7 @@ public final class AffineThemePlugin: CAPPlugin, CAPBridgedPlugin {
@objc func onThemeChanged(_ call: CAPPluginCall) {
DispatchQueue.main.async {
let themeMode = AffineThemeMode(rawValue: call.getString("themeMode") ?? "") ?? .system
UserDefaults.standard.set(themeMode.rawValue, forKey: AffineThemeStorage.modeKey)
(self.controller as? AffineThemeConfigurable)?.appThemeUserInterfaceStyle = themeMode.userInterfaceStyle
call.resolve()
}
@@ -1,3 +1,4 @@
import AffineGraphQL
import Capacitor
import Foundation
import Security
@@ -71,22 +72,25 @@ private actor AuthSessionBroker {
private var refreshTasks: [String: AuthRefreshOperation] = [:]
private var mutationEpochs: [String: UInt] = [:]
func store(_ endpoint: String, response: AuthTokenResponse) throws {
func store(_ endpoint: String, response: AuthTokenResponse) throws -> StoredAuthTokenPair {
invalidateRefresh(canonicalEndpoint(endpoint))
try write(endpoint, tokenPair(response))
let pair = try tokenPair(response)
try write(endpoint, pair)
return pair
}
func validAccessToken(_ endpoint: String, minValidity: TimeInterval = 120) async throws -> String?
func validAccessToken(_ endpoint: String, minValidity: TimeInterval = 120) async throws
-> StoredAuthTokenPair?
{
guard let pair = try read(endpoint) else { return nil }
if pair.accessExpiresAt.timeIntervalSinceNow > minValidity {
return pair.accessToken
return pair
}
return try await refresh(endpoint).accessToken
return try await refresh(endpoint)
}
func refreshAccessToken(_ endpoint: String) async throws -> String {
try await refresh(endpoint).accessToken
func refreshAccessToken(_ endpoint: String) async throws -> StoredAuthTokenPair {
try await refresh(endpoint)
}
func signOut(_ endpoint: String) async throws {
@@ -269,6 +273,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "AuthPlugin"
public let jsName = "Auth"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "showNativeSignIn", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signInMagicLink", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signInOauth", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signInOpenApp", returnType: CAPPluginReturnPromise),
@@ -281,6 +286,32 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
private let broker = AuthSessionBroker()
private let authCookieNames = Set(["affine_session", "affine_user_id", "affine_csrf_token"])
private var isNativeSignInPresented = false
@objc public func showNativeSignIn(_ call: CAPPluginCall) {
Task { @MainActor [weak self] in
guard let self else {
call.reject("Failed to show native sign-in")
return
}
do {
let webView = try self.webView.get("AFFiNE is still loading. Please try again in a moment.")
if self.isNativeSignInPresented {
call.resolve(["success": false])
return
}
self.isNativeSignInPresented = true
defer { self.isNativeSignInPresented = false }
let isSignedIn = try await PaywallAuthGuard.ensureSignedIn(using: webView)
call.resolve(["success": isSignedIn])
} catch {
call.reject("Failed to show native sign-in", nil, error)
}
}
}
private func canonicalEndpoint(_ endpoint: String) -> String {
guard let url = URL(string: endpoint), let scheme = url.scheme, let host = url.host else {
@@ -305,8 +336,12 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
Task {
do {
let endpoint = try call.getStringEnsure("endpoint")
let token = try await broker.validAccessToken(endpoint)
call.resolve(["token": token ?? NSNull()])
let pair = try await broker.validAccessToken(endpoint)
if let pair {
AuthAccessTokenCache.shared.set(
pair.accessToken, expiresAt: pair.accessExpiresAt, for: endpoint)
}
call.resolve(["token": pair?.accessToken ?? NSNull()])
} catch {
call.reject("Failed to get access token, \(error)", nil, error)
}
@@ -316,7 +351,9 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
@objc public func clearEndpointSession(_ call: CAPPluginCall) {
Task {
do {
try await broker.clear(call.getStringEnsure("endpoint"))
let endpoint = try call.getStringEnsure("endpoint")
try await broker.clear(endpoint)
AuthAccessTokenCache.shared.remove(for: endpoint)
call.resolve(["ok": true])
} catch {
call.reject("Failed to clear auth session, \(error)", nil, error)
@@ -327,8 +364,11 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
@objc public func refreshAccessToken(_ call: CAPPluginCall) {
Task {
do {
let token = try await broker.refreshAccessToken(call.getStringEnsure("endpoint"))
call.resolve(["token": token])
let endpoint = try call.getStringEnsure("endpoint")
let pair = try await broker.refreshAccessToken(endpoint)
AuthAccessTokenCache.shared.set(
pair.accessToken, expiresAt: pair.accessExpiresAt, for: endpoint)
call.resolve(["token": pair.accessToken])
} catch {
call.reject("Failed to refresh access token, \(error)", nil, error)
}
@@ -447,6 +487,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
do {
let endpoint = try call.getStringEnsure("endpoint")
try await broker.signOut(endpoint)
AuthAccessTokenCache.shared.remove(for: endpoint)
self.clearAuthCookies(endpoint)
call.resolve(["ok": true])
} catch {
@@ -483,8 +524,10 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
throw authServerError(data, statusCode: response.statusCode)
}
try await broker.store(
let pair = try await broker.store(
endpoint, response: JSONDecoder().decode(AuthTokenResponse.self, from: data))
AuthAccessTokenCache.shared.set(
pair.accessToken, expiresAt: pair.accessExpiresAt, for: endpoint)
self.clearAuthCookies(endpoint)
}
@@ -11,7 +11,6 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
) {
controller = associatedController
super.init()
Paywall.setup()
}
weak var controller: UIViewController?
@@ -23,23 +22,46 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
]
@objc func showPayWall(_ call: CAPPluginCall) {
do {
let type = try call.getStringEnsure("type")
let controller = try controller.get()
// TODO: GET TO KNOW THE PAYWALL TYPE
print("[*] showing paywall of type: \(type)")
DispatchQueue.main.async {
Paywall.presentWall(
toController: controller,
bindWebContext: self.webView,
type: type
)
Task { @MainActor [weak self] in
guard let self else {
call.resolve(["success": false])
return
}
call.resolve(["success": true, "type": type])
} catch {
call.reject("failed to show paywall", nil, error)
do {
let type = try call.getStringEnsure("type")
let presentingController = try controller.get()
let webView = try self.webView.get("AFFiNE is still loading. Please try again in a moment.")
let initialPlan = paywallPlan(for: type)
let isSignedIn = try await PaywallAuthGuard.ensureSignedIn(using: webView)
guard isSignedIn else {
call.resolve(["success": false, "type": type])
return
}
let paywallController = AppPaywallViewController()
paywallController.initialPlan = initialPlan
paywallController.bindWebView = webView
paywallController.modalPresentationStyle = .fullScreen
paywallController.modalTransitionStyle = .coverVertical
presentingController.present(paywallController, animated: true)
call.resolve(["success": true, "type": type])
} catch {
call.reject("failed to show paywall", nil, error)
}
}
}
private func paywallPlan(for type: String) -> AppPaywallPlan {
switch type.lowercased() {
case "lite":
.lite
case "ai":
.ai
default:
.pro
}
}
}
@@ -5,10 +5,16 @@
// Created by on 2024/11/18.
//
import AffineResources
import UIKit
import WebKit
@objc
class RootViewController: UINavigationController {
private var affineViewController: AFFiNEViewController?
private var didScheduleOnboardingPresentation = false
private var didRunColdStartPaywallFlow = false
private var coldStartPaywallRetryCount = 0
override init(rootViewController _: UIViewController) {
fatalError() // "you are not allowed to call this"
}
@@ -28,11 +34,228 @@ class RootViewController: UINavigationController {
func commitInit() {
assert(viewControllers.isEmpty)
viewControllers = [AFFiNEViewController()]
let affineViewController = AFFiNEViewController()
self.affineViewController = affineViewController
viewControllers = [affineViewController]
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !presentOnboardingIfNeeded() {
runColdStartPaywallFlowIfNeeded()
}
}
@discardableResult
private func presentOnboardingIfNeeded(force: Bool = false) -> Bool {
guard force || !didScheduleOnboardingPresentation else { return false }
guard force || !OnboardingFlag.isCompleted else { return false }
guard presentedViewController == nil else { return false }
didScheduleOnboardingPresentation = true
let onboardingController = OnboardingViewController()
onboardingController.modalPresentationStyle = .fullScreen
onboardingController.onCompleteOnboarding = { [weak self, weak onboardingController] in
self?.handleOnboardingCompletion(from: onboardingController)
}
present(onboardingController, animated: false)
return true
}
private func runColdStartPaywallFlowIfNeeded() {
guard OnboardingFlag.isCompleted else { return }
guard !didRunColdStartPaywallFlow else { return }
guard presentedViewController == nil else {
scheduleColdStartPaywallFlowRetry()
return
}
guard let webView = affineViewController?.webView else {
scheduleColdStartPaywallFlowRetry()
return
}
didRunColdStartPaywallFlow = true
Task { @MainActor [weak self, weak webView] in
guard let self, let webView else { return }
do {
try await waitForColdStartHomeDocReady(in: webView)
let isAlreadySignedIn = await PaywallAuthGuard.currentUserIdentifier(in: webView) != nil
if !isAlreadySignedIn {
let action = await presentColdStartSignInSheet()
guard action == .seeProBenefits else { return }
}
let isSignedIn = try await PaywallAuthGuard.ensureSignedIn(using: webView)
guard isSignedIn else {
return
}
if try await PaywallAuthGuard.hasProSubscription(in: webView) {
return
}
presentSharedPaywall(initialPlan: .pro, bindWebView: webView)
} catch {
didRunColdStartPaywallFlow = false
scheduleColdStartPaywallFlowRetry()
}
}
}
private func scheduleColdStartPaywallFlowRetry() {
guard coldStartPaywallRetryCount < 3 else { return }
coldStartPaywallRetryCount += 1
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 1_000_000_000)
self?.runColdStartPaywallFlowIfNeeded()
}
}
@MainActor
private func presentColdStartSignInSheet() async -> ColdStartSignInSheetViewController.Action {
guard presentedViewController == nil else { return .continueFree }
return await withCheckedContinuation { continuation in
let controller = ColdStartSignInSheetViewController()
controller.onAction = { action in
continuation.resume(returning: action)
}
present(controller, animated: true)
}
}
private func waitForColdStartHomeDocReady(in webView: WKWebView) async throws {
let deadline = Date().addingTimeInterval(20)
while Date() < deadline {
if await isHomeDocReady(in: webView) {
return
}
try await Task.sleep(nanoseconds: 250_000_000)
}
throw NSError(
domain: "RootViewController",
code: -1,
userInfo: [NSLocalizedDescriptionKey: String(localized: "AFFiNE home is still loading.")]
)
}
private func isHomeDocReady(in webView: WKWebView) async -> Bool {
do {
let result = try await webView.callAsyncJavaScript(
"""
const bridgeReady = typeof window.getCurrentUserIdentifier === 'function'
&& typeof window.showNativeSignIn === 'function';
const bodyReady = Boolean(document.body && document.body.children.length > 0);
return document.readyState === 'complete' && bridgeReady && bodyReady;
""",
contentWorld: .page
)
return (result as? Bool) == true
} catch {
return false
}
}
private func handleOnboardingCompletion(from onboardingController: OnboardingViewController?) {
Task { @MainActor [weak self, weak onboardingController] in
guard let self else { return }
OnboardingFlag.markCompleted()
didRunColdStartPaywallFlow = true
guard let webView = affineViewController?.webView else {
showOnboardingAlert(message: String(localized: "AFFiNE is still loading. Please try again in a moment."))
return
}
let isSignedIn: Bool
do {
isSignedIn = try await PaywallAuthGuard.ensureSignedIn(
using: webView,
dismissing: onboardingController
)
} catch {
showOnboardingAlert(message: error.localizedDescription)
return
}
guard isSignedIn else {
return
}
do {
if try await PaywallAuthGuard.hasProSubscription(in: webView) {
finishOnboarding(from: nil)
return
}
} catch {
showOnboardingAlert(message: error.localizedDescription)
finishOnboarding(from: nil)
return
}
presentSharedPaywall(
initialPlan: .pro,
bindWebView: webView,
onClose: { [weak self] in
self?.finishOnboarding(from: nil)
},
onPurchaseCompleted: { [weak self] in
self?.finishOnboarding(from: nil)
}
)
}
}
@MainActor
private func presentSharedPaywall(
initialPlan: AppPaywallPlan,
bindWebView webView: WKWebView,
onClose: (() -> Void)? = nil,
onPurchaseCompleted: (() -> Void)? = nil
) {
guard presentedViewController == nil else { return }
let paywallController = AppPaywallViewController()
paywallController.initialPlan = initialPlan
paywallController.bindWebView = webView
paywallController.modalPresentationStyle = .fullScreen
paywallController.modalTransitionStyle = .coverVertical
paywallController.onClose = onClose
paywallController.onPurchaseCompleted = onPurchaseCompleted
present(paywallController, animated: true)
}
private func finishOnboarding(from controller: UIViewController?) {
OnboardingFlag.markCompleted()
affineViewController?.webView?.evaluateJavaScript("window.location.assign('/');")
guard let controller, controller.presentingViewController != nil else {
didScheduleOnboardingPresentation = true
return
}
controller.dismiss(animated: true) { [weak self] in
self?.didScheduleOnboardingPresentation = true
}
}
@MainActor
private func showOnboardingAlert(message: String) {
let alert = UIAlertController(
title: String(localized: "Onboarding"),
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: String(localized: "OK"), style: .default))
(presentedViewController ?? self).present(alert, animated: true)
}
}
@@ -0,0 +1,200 @@
import SwiftUI
struct LoginDotOverlay: View {
let dotColor: Color
private let spacing: CGFloat = 13
private let dotSize: CGFloat = 2.2
var body: some View {
Canvas { context, size in
let dot = Path(ellipseIn: CGRect(x: 0, y: 0, width: dotSize, height: dotSize))
for x in stride(from: CGFloat(0), through: size.width, by: spacing) {
for y in stride(from: CGFloat(0), through: size.height, by: spacing) {
context.fill(
dot.applying(CGAffineTransform(translationX: x, y: y)),
with: .color(dotColor)
)
}
}
}
}
}
struct LoginRedGridOverlay: View {
private let spacing: CGFloat = 12
var body: some View {
GeometryReader { geometry in
let drawWidth = geometry.size.width
let drawHeight = geometry.size.height
Path { path in
stride(from: 0, through: drawWidth, by: spacing).forEach { x in
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: drawHeight))
}
stride(from: 0, through: drawHeight, by: spacing).forEach { y in
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: drawWidth, y: y))
}
}
.stroke(Color.red.opacity(0.4), lineWidth: 0.4)
.frame(width: drawWidth, height: drawHeight, alignment: .topLeading)
}
}
}
struct OAuthButton: View {
let provider: NativeOAuthProvider
let palette: NativeSignInPalette
let isLoading: Bool
let isDisabled: Bool
let action: () -> Void
private var foreground: Color {
provider == .google ? .white : palette.appleButtonForeground
}
var body: some View {
Button(action: action) {
HStack(spacing: 13) {
if isLoading {
ProgressView()
.tint(foreground)
.frame(width: 28, height: 28)
} else {
providerIcon
.frame(width: 28, height: 28)
}
Text(LocalizedStringKey(provider == .google ? "Continue with Google" : "Continue with Apple"))
.font(.system(size: 18, weight: .medium))
}
.foregroundStyle(foreground)
.frame(maxWidth: .infinity)
.frame(height: palette.authButtonHeight)
.background(provider == .google ? palette.googleButtonBackground : palette.appleButtonBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.authButtonCornerRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: palette.authButtonCornerRadius, style: .continuous)
.stroke(provider == .apple ? palette.appleButtonBorder : Color.clear, lineWidth: palette.appleButtonBorderWidth)
}
.shadow(color: palette.controlShadow.opacity(0.7), radius: 8, x: 0, y: 3)
}
.buttonStyle(.plain)
.disabled(isDisabled)
.opacity(isDisabled && !isLoading ? 0.62 : 1)
}
@ViewBuilder
private var providerIcon: some View {
if provider == .google {
Text("G")
.font(.system(size: 32, weight: .bold))
.foregroundStyle(.white)
} else {
Image(systemName: "apple.logo")
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(foreground)
}
}
}
struct EmailContinueButton: View {
let palette: NativeSignInPalette
let isLoading: Bool
let isDisabled: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.tint(palette.accent)
}
Text("Continue with Email")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(palette.emailButtonText)
Image(systemName: "arrow.right")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(palette.accent)
}
.frame(maxWidth: .infinity)
.frame(height: palette.emailButtonHeight)
.background(palette.emailButtonBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.emailButtonCornerRadius, style: .continuous))
.shadow(color: palette.controlShadow.opacity(0.65), radius: 10, x: 0, y: 4)
}
.buttonStyle(.plain)
.disabled(isDisabled)
.opacity(isDisabled && !isLoading ? 0.62 : 1)
}
}
struct LegalLinkButton: View {
let title: String
let palette: NativeSignInPalette
let action: () -> Void
var body: some View {
Button(action: action) {
Text(LocalizedStringKey(title))
.font(.system(size: 12, weight: .medium))
.underline(true, color: palette.accent)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
}
}
struct PrimaryNativeSignInButton: View {
let title: String
let palette: NativeSignInPalette
let isLoading: Bool
let isEnabled: Bool
let isDisabled: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.tint(.white)
}
Text(LocalizedStringKey(title))
.font(.system(size: 17, weight: .bold))
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: palette.emailButtonHeight)
.background(isEnabled && (!isDisabled || isLoading) ? palette.accent : palette.primaryDisabledBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.emailButtonCornerRadius, style: .continuous))
}
.buttonStyle(.plain)
.disabled(!isEnabled || isDisabled)
}
}
struct FooterLinkButton: View {
let systemName: String
let title: String
let palette: NativeSignInPalette
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 9) {
Image(systemName: systemName)
.font(.system(size: 21, weight: .medium))
Text(LocalizedStringKey(title))
.font(.system(size: 16, weight: .regular))
}
.foregroundStyle(palette.accent)
.frame(maxWidth: .infinity)
.frame(height: 34)
}
.buttonStyle(.plain)
}
}
@@ -0,0 +1,241 @@
import SwiftUI
struct NativeSignInHUDView: View {
let message: String
let isSuccess: Bool
let palette: NativeSignInPalette
@State private var successProgress: CGFloat = 0
@State private var successCheckScale: CGFloat = 0.56
@State private var successCheckOpacity: Double = 0
@State private var isSuccessAnimationSettled = false
private var usesCompactSuccessLayout: Bool {
isSuccess && isSuccessAnimationSettled
}
var body: some View {
content
.padding(.horizontal, usesCompactSuccessLayout ? 10 : 24)
.padding(.vertical, usesCompactSuccessLayout ? 10 : 22)
.background {
RoundedRectangle(cornerRadius: usesCompactSuccessLayout ? 22 : 30, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: usesCompactSuccessLayout ? 22 : 30, style: .continuous)
.fill(palette.hudBackground)
}
}
.overlay {
RoundedRectangle(cornerRadius: usesCompactSuccessLayout ? 22 : 30, style: .continuous)
.stroke(palette.hudBorder, lineWidth: 1)
}
.shadow(color: palette.hudShadow, radius: 24, x: 0, y: 14)
.shadow(color: palette.controlShadow, radius: 6, x: 0, y: 2)
.allowsHitTesting(false)
.onAppear {
guard isSuccess else { return }
startSuccessAnimation()
}
.onChange(of: isSuccess) { value in
if value {
startSuccessAnimation()
} else {
resetSuccessAnimation()
}
}
}
@ViewBuilder
private var content: some View {
if isSuccess {
if isSuccessAnimationSettled {
successContent
} else {
animatedSuccessContent
}
} else {
loadingContent
}
}
private var loadingContent: some View {
VStack(spacing: 14) {
NativeSignInLoadingRing(palette: palette)
Text(message)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.primaryText)
.lineLimit(1)
}
}
private var animatedSuccessContent: some View {
VStack(spacing: 14) {
NativeSignInSuccessRing(
palette: palette,
progress: successProgress,
checkScale: successCheckScale,
checkOpacity: successCheckOpacity
)
Text(message)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.primaryText)
.lineLimit(1)
}
}
private var successContent: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(palette.successText.opacity(0.14))
.frame(width: 34, height: 34)
Image(systemName: "checkmark")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(palette.successText)
}
Text(message)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(palette.primaryText)
.lineLimit(1)
}
}
private func startSuccessAnimation() {
resetSuccessAnimation()
withAnimation(.easeOut(duration: 0.28)) {
successProgress = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
withAnimation(.spring(response: 0.42, dampingFraction: 0.62)) {
successCheckScale = 1
successCheckOpacity = 1
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.76) {
withAnimation(.easeInOut(duration: 0.18)) {
isSuccessAnimationSettled = true
}
}
}
private func resetSuccessAnimation() {
successProgress = 0
successCheckScale = 0.56
successCheckOpacity = 0
isSuccessAnimationSettled = false
}
}
private struct NativeSignInSuccessRing: View {
let palette: NativeSignInPalette
let progress: CGFloat
let checkScale: CGFloat
let checkOpacity: Double
var body: some View {
ZStack {
Circle()
.stroke(palette.hudLoadingTrack, lineWidth: 7)
.frame(width: 76, height: 76)
Circle()
.trim(from: 0, to: progress)
.stroke(
LinearGradient(
colors: [palette.accent, palette.hudGradientEnd],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
style: StrokeStyle(lineWidth: 7, lineCap: .round)
)
.frame(width: 76, height: 76)
.rotationEffect(.degrees(-90))
Image(systemName: "checkmark")
.font(.system(size: 32, weight: .bold))
.foregroundStyle(palette.successText)
.scaleEffect(checkScale)
.opacity(checkOpacity)
}
}
}
private struct NativeSignInLoadingRing: View {
let palette: NativeSignInPalette
@State private var rotation: Double = -90
@State private var pulseScale: CGFloat = 0.94
@State private var pulseOpacity: Double = 0.44
var body: some View {
ZStack {
Circle()
.stroke(palette.hudLoadingTrack, lineWidth: 6.5)
.frame(width: 76, height: 76)
Circle()
.trim(from: 0.06, to: 0.36)
.stroke(
LinearGradient(
colors: [palette.accent, palette.hudGradientEnd],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
style: StrokeStyle(lineWidth: 7, lineCap: .round)
)
.frame(width: 76, height: 76)
.rotationEffect(.degrees(rotation))
.shadow(color: palette.accent.opacity(0.26), radius: 7, x: 0, y: 0)
Circle()
.trim(from: 0.58, to: 0.72)
.stroke(
LinearGradient(
colors: [palette.hudGradientEnd.opacity(0.72), palette.accent.opacity(0.22)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
style: StrokeStyle(lineWidth: 4.5, lineCap: .round)
)
.frame(width: 76, height: 76)
.rotationEffect(.degrees(rotation + 12))
Circle()
.fill(
RadialGradient(
colors: [palette.accent.opacity(0.20), Color.clear],
center: .center,
startRadius: 0,
endRadius: 26
)
)
.frame(width: 52, height: 52)
.scaleEffect(pulseScale)
.opacity(pulseOpacity)
Circle()
.fill(palette.accent.opacity(0.82))
.frame(width: 5.5, height: 5.5)
.scaleEffect(pulseScale)
.opacity(0.86)
}
.onAppear {
rotation = -90
pulseScale = 0.94
pulseOpacity = 0.44
withAnimation(.linear(duration: 1.08).repeatForever(autoreverses: false)) {
rotation = 270
}
withAnimation(.easeInOut(duration: 0.92).repeatForever(autoreverses: true)) {
pulseScale = 1.12
pulseOpacity = 0.72
}
}
}
}
@@ -0,0 +1,224 @@
import SwiftUI
import UIKit
enum NativeSignInAppearanceSetting: String {
case system
case light
case dark
}
struct NativeSignInAppearanceSnapshot {
let setting: NativeSignInAppearanceSetting
let resolvedScheme: ColorScheme
init(setting: NativeSignInAppearanceSetting, resolvedScheme: ColorScheme) {
self.setting = setting
self.resolvedScheme = resolvedScheme
}
init(systemInterfaceStyle: UIUserInterfaceStyle) {
setting = .system
resolvedScheme = systemInterfaceStyle == .dark ? .dark : .light
}
init(cachedSystemInterfaceStyle systemInterfaceStyle: UIUserInterfaceStyle) {
let cachedSetting = NativeSignInAppearanceSetting(rawValue: UserDefaults.standard.string(forKey: AffineThemeStorage.modeKey) ?? "") ?? .system
setting = cachedSetting
switch cachedSetting {
case .dark:
resolvedScheme = .dark
case .light:
resolvedScheme = .light
case .system:
resolvedScheme = systemInterfaceStyle == .dark ? .dark : .light
}
}
func effectiveScheme(systemScheme: ColorScheme) -> ColorScheme {
setting == .system ? systemScheme : resolvedScheme
}
var preferredColorScheme: ColorScheme? {
switch setting {
case .system:
return nil
case .light:
return .light
case .dark:
return .dark
}
}
}
struct NativeSignInPalette {
let colorScheme: ColorScheme
private var isDark: Bool {
colorScheme == .dark
}
var usesDarkStyle: Bool {
isDark
}
var background: Color {
isDark ? Color(red: 0.30, green: 0.30, blue: 0.30) : Color(red: 0.95, green: 0.95, blue: 0.95)
}
var backgroundOverlay: Color {
isDark ? Color.black.opacity(0.34) : Color.white.opacity(0.02)
}
var backgroundImageOpacity: Double {
isDark ? 0.68 : 1
}
var dotColor: Color {
isDark ? Color.white.opacity(0.12) : Color.clear
}
var logoTint: Color {
isDark ? Color.white.opacity(0.94) : Color(red: 0.12, green: 0.12, blue: 0.12)
}
var primaryText: Color {
isDark ? Color(red: 0.95, green: 0.95, blue: 0.96) : Color(red: 0.12, green: 0.12, blue: 0.12)
}
var secondaryText: Color {
isDark ? Color(red: 0.72, green: 0.72, blue: 0.74) : Color(red: 0.22, green: 0.22, blue: 0.22)
}
var placeholderText: Color {
isDark ? Color.white.opacity(0.58) : Color(red: 0.55, green: 0.55, blue: 0.55)
}
var tertiaryText: Color {
isDark ? Color(red: 0.62, green: 0.62, blue: 0.64) : Color(red: 0.55, green: 0.55, blue: 0.55)
}
var inputBackground: Color {
isDark ? Color(red: 0.25, green: 0.25, blue: 0.25) : Color(red: 0.88, green: 0.88, blue: 0.88)
}
var readonlyInputBackground: Color {
isDark ? Color(red: 0.22, green: 0.22, blue: 0.22) : Color(red: 0.90, green: 0.90, blue: 0.90)
}
var divider: Color {
isDark ? Color.white.opacity(0.12) : Color(red: 0.86, green: 0.86, blue: 0.86)
}
var accent: Color {
isDark ? Color(red: 0.43, green: 0.78, blue: 1.0) : Color(red: 0.0, green: 0.43, blue: 1.0)
}
var successText: Color {
isDark ? Color(red: 0.44, green: 0.86, blue: 0.57) : Color(red: 0.10, green: 0.58, blue: 0.25)
}
var hudBackground: Color {
isDark ? Color.white.opacity(0.10) : Color.white.opacity(0.72)
}
var hudBorder: Color {
isDark ? Color.white.opacity(0.16) : Color.white.opacity(0.86)
}
var hudIconBackground: Color {
isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.05)
}
var hudLoadingTrack: Color {
isDark ? Color.white.opacity(0.13) : Color.black.opacity(0.07)
}
var hudGradientEnd: Color {
isDark ? Color(red: 0.68, green: 0.45, blue: 1.0) : Color(red: 0.62, green: 0.30, blue: 0.96)
}
var hudShadow: Color {
isDark ? Color.black.opacity(0.32) : Color.black.opacity(0.14)
}
var closeIcon: Color {
isDark ? Color.white.opacity(0.92) : Color(red: 0.18, green: 0.18, blue: 0.18)
}
var closeButtonBackground: Color {
isDark ? Color(red: 0.16, green: 0.16, blue: 0.16).opacity(0.92) : Color.white.opacity(0.94)
}
var closeButtonCornerRadius: CGFloat {
isDark ? 13 : 22
}
var controlShadow: Color {
isDark ? Color.black.opacity(0.10) : Color.black.opacity(0.08)
}
var inputCornerRadius: CGFloat {
isDark ? 6 : 10
}
var inputHeight: CGFloat {
isDark ? 44 : 47
}
var authButtonHeight: CGFloat {
isDark ? 44 : 52
}
var emailButtonHeight: CGFloat {
isDark ? 44 : 50
}
var authButtonCornerRadius: CGFloat {
isDark ? 7 : 26
}
var emailButtonCornerRadius: CGFloat {
isDark ? 7 : 25
}
var emailButtonBackground: Color {
isDark ? Color(red: 0.28, green: 0.28, blue: 0.28) : Color.white.opacity(0.96)
}
var emailButtonText: Color {
isDark ? Color.white.opacity(0.92) : Color(red: 0.10, green: 0.10, blue: 0.10)
}
var googleButtonBackground: Color {
Color(red: 0.11, green: 0.58, blue: 0.91)
}
var appleButtonBackground: Color {
isDark ? Color.black.opacity(0.05) : Color.black
}
var appleButtonForeground: Color {
Color.white
}
var appleButtonBorder: Color {
isDark ? Color.white.opacity(0.82) : Color.clear
}
var appleButtonBorderWidth: CGFloat {
isDark ? 1 : 0
}
var primaryDisabledBackground: Color {
isDark ? Color(red: 0.28, green: 0.28, blue: 0.28) : Color.gray.opacity(0.5)
}
var legalFrameAlignment: Alignment {
isDark ? .leading : .center
}
var legalTextAlignment: TextAlignment {
isDark ? .leading : .center
}
}
@@ -0,0 +1,388 @@
import SwiftUI
struct NativeSignInView: View {
@ObservedObject var viewModel: NativeSignInViewModel
@Environment(\.openURL) private var openURL
@Environment(\.colorScheme) private var systemColorScheme
private var palette: NativeSignInPalette {
NativeSignInPalette(colorScheme: viewModel.appearance.effectiveScheme(systemScheme: systemColorScheme))
}
var body: some View {
GeometryReader { geometry in
let layout = NativeSignInLayout(
size: geometry.size,
safeAreaInsets: geometry.safeAreaInsets
)
ZStack(alignment: .top) {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
Color.clear
.frame(height: layout.headerHeight)
VStack(alignment: .leading, spacing: layout.contentSpacing) {
titleSection
switch viewModel.step {
case .signIn:
signInStep
case .password:
passwordStep
case .magicCode:
magicCodeStep
}
}
.padding(.horizontal, layout.horizontalPadding)
.padding(.bottom, layout.bottomPadding)
.frame(maxWidth: layout.maxContentWidth, alignment: .leading)
.frame(maxWidth: .infinity)
}
.frame(minHeight: geometry.size.height, alignment: .top)
}
.zIndex(2)
if palette.usesDarkStyle {
LoginDotOverlay(dotColor: palette.dotColor)
.ignoresSafeArea()
.allowsHitTesting(false)
.zIndex(1)
} else {
LoginRedGridOverlay()
.ignoresSafeArea()
.allowsHitTesting(false)
.zIndex(10)
}
Button(action: viewModel.close) {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .bold))
.foregroundStyle(palette.closeIcon)
.frame(width: 44, height: 44)
.background(palette.closeButtonBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.closeButtonCornerRadius, style: .continuous))
.shadow(color: palette.controlShadow, radius: 10, x: 0, y: 4)
}
.buttonStyle(.plain)
.padding(.top, layout.closeButtonTopPadding)
.padding(.trailing, layout.closeButtonTrailingPadding)
.frame(maxWidth: .infinity, alignment: .topTrailing)
.zIndex(11)
if viewModel.isLoading || viewModel.isSuccessFeedback {
NativeSignInHUDView(
message: viewModel.isSuccessFeedback
? String(localized: "Sign in Success")
: String(localized: "Logging in..."),
isSuccess: viewModel.isSuccessFeedback,
palette: palette
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
.zIndex(12)
}
}
.animation(.easeInOut(duration: 0.16), value: viewModel.isLoading)
.animation(.easeInOut(duration: 0.16), value: viewModel.isSuccessFeedback)
.frame(width: geometry.size.width, height: geometry.size.height)
}
.preferredColorScheme(viewModel.appearance.preferredColorScheme)
}
private var titleSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Image("NativeLoginLogo")
.resizable()
.renderingMode(.template)
.scaledToFit()
.foregroundStyle(palette.logoTint)
.frame(width: 36, height: 36)
Text("Sign in")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(palette.primaryText)
}
Text("AFFiNE Cloud")
.font(.system(size: 23, weight: .bold))
.foregroundStyle(palette.primaryText)
}
}
private var signInStep: some View {
VStack(alignment: .leading, spacing: 13) {
OAuthButton(
provider: .google,
palette: palette,
isLoading: viewModel.isProviderLoading(.google),
isDisabled: viewModel.isLoading
) {
viewModel.startOAuth(provider: .google)
}
OAuthButton(
provider: .apple,
palette: palette,
isLoading: viewModel.isProviderLoading(.apple),
isDisabled: viewModel.isLoading
) {
viewModel.startOAuth(provider: .apple)
}
VStack(alignment: .leading, spacing: 10) {
Text("Email")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(palette.secondaryText)
TextField(
"",
text: $viewModel.email,
prompt: Text("Enter your email address").foregroundColor(palette.placeholderText)
)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(.system(size: 16, weight: .regular))
.foregroundStyle(palette.primaryText)
.tint(palette.accent)
.padding(.horizontal, 16)
.frame(height: palette.inputHeight)
.background(palette.inputBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.inputCornerRadius, style: .continuous))
.submitLabel(.continue)
.onSubmit(viewModel.continueWithEmail)
}
.padding(.top, 8)
EmailContinueButton(
palette: palette,
isLoading: viewModel.isActionLoading(.email),
isDisabled: viewModel.isLoading
) {
viewModel.continueWithEmail()
}
feedbackText
legalText
.padding(.top, 8)
divider
.padding(.vertical, 2)
FooterLinkButton(systemName: "globe", title: "Connect to a Self-Hosted Instance", palette: palette) {
viewModel.openSelfHosted()
}
FooterLinkButton(systemName: "person.crop.square", title: "Start AFFiNE without an account", palette: palette) {
viewModel.close()
}
}
}
private var passwordStep: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Email")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(palette.secondaryText)
Text(viewModel.email)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(palette.secondaryText)
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: palette.inputHeight)
.background(palette.readonlyInputBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.inputCornerRadius, style: .continuous))
SecureField(
"",
text: $viewModel.password,
prompt: Text("Enter your password").foregroundColor(palette.placeholderText)
)
.textContentType(.password)
.font(.system(size: 16, weight: .regular))
.foregroundStyle(palette.primaryText)
.tint(palette.accent)
.padding(.horizontal, 16)
.frame(height: palette.inputHeight)
.background(palette.inputBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.inputCornerRadius, style: .continuous))
.submitLabel(.go)
.onSubmit(viewModel.signInWithPassword)
PrimaryNativeSignInButton(
title: "Continue",
palette: palette,
isLoading: viewModel.isActionLoading(.password),
isEnabled: viewModel.canContinueWithPassword,
isDisabled: viewModel.isLoading,
action: viewModel.signInWithPassword
)
if viewModel.canUseMagicLink {
Button("Use email code instead", action: viewModel.useMagicLink)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(palette.accent)
.buttonStyle(.plain)
}
feedbackText
}
}
private var magicCodeStep: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Enter the code sent to")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(palette.secondaryText)
Text(viewModel.email)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.primaryText)
TextField(
"",
text: $viewModel.magicCode,
prompt: Text("6-digit code").foregroundColor(palette.placeholderText)
)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(palette.primaryText)
.tint(palette.accent)
.padding(.horizontal, 16)
.frame(height: palette.inputHeight)
.background(palette.inputBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.inputCornerRadius, style: .continuous))
.onChange(of: viewModel.magicCode) { value in
viewModel.magicCode = String(value.prefix(6))
}
PrimaryNativeSignInButton(
title: "Continue",
palette: palette,
isLoading: viewModel.isActionLoading(.magicCode),
isEnabled: viewModel.canContinueWithCode,
isDisabled: viewModel.isLoading,
action: viewModel.signInWithMagicCode
)
Button("Resend code") {
viewModel.resendMagicCode()
}
.font(.system(size: 15, weight: .medium))
.foregroundStyle(palette.accent)
.buttonStyle(.plain)
feedbackText
}
}
private var legalText: some View {
VStack(alignment: palette.usesDarkStyle ? .leading : .center, spacing: 3) {
Text("By clicking \"Continue with Google/Email\" above, you")
Text("acknowledge that you agree to AFFiNE's")
HStack(spacing: 3) {
LegalLinkButton(title: "Terms of Conditions", palette: palette) {
openLegalURL("https://affine.pro/terms")
}
Text("and")
LegalLinkButton(title: "Privacy Policy", palette: palette) {
openLegalURL("https://affine.pro/privacy")
}
Text(".")
}
}
.font(.system(size: 12, weight: .medium))
.foregroundStyle(palette.tertiaryText)
.frame(maxWidth: .infinity, alignment: palette.legalFrameAlignment)
.multilineTextAlignment(palette.legalTextAlignment)
}
private var divider: some View {
HStack(spacing: 14) {
Rectangle()
.fill(palette.divider)
.frame(height: 1)
Text("Or")
.font(.system(size: 16, weight: .regular))
.foregroundStyle(palette.secondaryText)
Rectangle()
.fill(palette.divider)
.frame(height: 1)
}
}
@ViewBuilder
private var feedbackText: some View {
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
} else if let statusMessage = viewModel.statusMessage {
HStack(spacing: 8) {
if viewModel.isLoading {
ProgressView()
.tint(palette.accent)
}
Text(statusMessage)
.font(.system(size: 13, weight: .medium))
}
.foregroundStyle(viewModel.isSuccessFeedback ? palette.successText : palette.tertiaryText)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func openLegalURL(_ urlString: String) {
guard let url = URL(string: urlString) else { return }
openURL(url)
}
}
private struct NativeSignInLayout {
let size: CGSize
let safeAreaInsets: EdgeInsets
private var isLandscape: Bool {
size.width > size.height
}
private var isPadLike: Bool {
min(size.width, size.height) >= 700
}
var headerHeight: CGFloat {
if isLandscape {
return isPadLike ? 170 : 118
}
return isPadLike ? 250 : 226
}
var contentSpacing: CGFloat {
isLandscape && !isPadLike ? 14 : 20
}
var horizontalPadding: CGFloat {
isLandscape ? 24 : 20
}
var bottomPadding: CGFloat {
max(safeAreaInsets.bottom + (isLandscape ? 16 : 18), isLandscape ? 24 : 34)
}
var maxContentWidth: CGFloat {
if isPadLike {
return 460
}
return isLandscape ? 520 : 420
}
var closeButtonTopPadding: CGFloat {
safeAreaInsets.top + max((44 - closeButtonSize) / 2, 0)
}
var closeButtonTrailingPadding: CGFloat {
max(safeAreaInsets.trailing + 8, 12)
}
private var closeButtonSize: CGFloat {
44
}
}
@@ -0,0 +1,109 @@
import SwiftUI
import UIKit
import WebKit
final class NativeSignInViewController: UIViewController {
var onComplete: ((Bool) -> Void)?
private let viewModel: NativeSignInViewModel
private let backgroundImageView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "NativeLoginBackground"))
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.isUserInteractionEnabled = false
return imageView
}()
private let backgroundOverlayView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.isUserInteractionEnabled = false
return view
}()
private var didComplete = false
init(webView: WKWebView) {
viewModel = NativeSignInViewModel(bridge: NativeSignInWebBridge(webView: webView))
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
modalTransitionStyle = .coverVertical
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
installBackgroundViews()
updateBackground(for: viewModel.appearance)
viewModel.onFinished = { [weak self] isSignedIn in
self?.finish(isSignedIn: isSignedIn)
}
viewModel.onAppearanceChanged = { [weak self] appearance in
self?.updateBackground(for: appearance)
}
viewModel.loadAppearance()
let hostingController = UIHostingController(rootView: NativeSignInView(viewModel: viewModel))
addChild(hostingController)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
hostingController.view.backgroundColor = .clear
view.addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hostingController.didMove(toParent: self)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updateBackgroundImageOffset()
}
private func installBackgroundViews() {
view.addSubview(backgroundImageView)
view.addSubview(backgroundOverlayView)
NSLayoutConstraint.activate([
backgroundImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: -24),
backgroundImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 24),
backgroundImageView.topAnchor.constraint(equalTo: view.topAnchor, constant: -80),
backgroundImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 80),
backgroundOverlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
backgroundOverlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
backgroundOverlayView.topAnchor.constraint(equalTo: view.topAnchor),
backgroundOverlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
private func updateBackgroundImageOffset() {
let isLandscape = view.bounds.width > view.bounds.height
backgroundImageView.transform = CGAffineTransform(translationX: 0, y: isLandscape ? 14 : 34)
}
private func updateBackground(for appearance: NativeSignInAppearanceSnapshot) {
let isDark = appearance.resolvedScheme == .dark
view.backgroundColor = isDark
? UIColor(red: 0.30, green: 0.30, blue: 0.30, alpha: 1)
: UIColor(red: 0.95, green: 0.95, blue: 0.95, alpha: 1)
backgroundImageView.alpha = isDark ? 0.68 : 1
backgroundOverlayView.backgroundColor = isDark
? UIColor.black.withAlphaComponent(0.34)
: UIColor.white.withAlphaComponent(0.02)
}
private func finish(isSignedIn: Bool) {
guard !didComplete else { return }
didComplete = true
view.isUserInteractionEnabled = false
dismiss(animated: false) { [onComplete] in
onComplete?(isSignedIn)
}
}
}
@@ -0,0 +1,270 @@
import SwiftUI
@MainActor
final class NativeSignInViewModel: ObservableObject {
enum Step {
case signIn
case password
case magicCode
}
enum LoadingAction: Equatable {
case googleOAuth
case appleOAuth
case email
case password
case magicCode
case resendCode
case selfHosted
}
@Published var step: Step = .signIn
@Published var email = ""
@Published var password = ""
@Published var magicCode = ""
@Published var isLoading = false
@Published var loadingAction: LoadingAction?
@Published var statusMessage: String?
@Published var errorMessage: String?
@Published var isSuccessFeedback = false
@Published var appearance: NativeSignInAppearanceSnapshot {
didSet {
onAppearanceChanged?(appearance)
}
}
var onFinished: ((Bool) -> Void)?
var onAppearanceChanged: ((NativeSignInAppearanceSnapshot) -> Void)?
private let bridge: NativeSignInWebBridge
private var actionTask: Task<Void, Never>?
private var appearanceTask: Task<Void, Never>?
private var emailMethods: NativeSignInWebBridge.EmailMethods?
init(bridge: NativeSignInWebBridge) {
self.bridge = bridge
appearance = NativeSignInAppearanceSnapshot(cachedSystemInterfaceStyle: UITraitCollection.current.userInterfaceStyle)
}
func loadAppearance() {
appearanceTask?.cancel()
appearanceTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
appearance = try await bridge.getAppearanceSnapshot()
try Task.checkCancellation()
} catch {
guard !Task.isCancelled else { return }
appearance = NativeSignInAppearanceSnapshot(cachedSystemInterfaceStyle: UITraitCollection.current.userInterfaceStyle)
}
}
}
var canContinueWithEmail: Bool {
isValidEmail(email)
}
var canContinueWithPassword: Bool {
!password.isEmpty
}
var canContinueWithCode: Bool {
magicCode.count == 6 && magicCode.allSatisfy { $0.isNumber }
}
var canUseMagicLink: Bool {
emailMethods?.canUseMagicLink == true
}
func close() {
actionTask?.cancel()
appearanceTask?.cancel()
bridge.cancel()
finishLoading()
onFinished?(false)
}
func isProviderLoading(_ provider: NativeOAuthProvider) -> Bool {
loadingAction == loadingAction(for: provider)
}
func isActionLoading(_ action: LoadingAction) -> Bool {
loadingAction == action
}
func startOAuth(provider: NativeOAuthProvider) {
guard !isLoading else { return }
let status: String = switch provider {
case .google: String(localized: "Waiting for Google sign-in...")
case .apple: String(localized: "Waiting for Apple sign-in...")
}
beginLoading(loadingAction(for: provider), status: status)
runAction { [weak self] in
guard let self else { return }
try await bridge.startOAuth(provider: provider)
try Task.checkCancellation()
statusMessage = String(localized: "Finishing sign-in...")
if try await bridge.waitForAuthenticated() != nil {
try await showSuccessAndFinish()
} else {
showError(String(localized: "Unable to sign in. Please try again."))
}
}
}
func continueWithEmail() {
guard !isLoading else { return }
email = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard canContinueWithEmail else {
showError(String(localized: "Enter a valid email address."))
return
}
beginLoading(.email, status: String(localized: "Checking sign-in method..."))
runAction { [weak self] in
guard let self else { return }
let methods = try await bridge.checkEmailMethods(email: email)
try Task.checkCancellation()
emailMethods = methods
if methods.hasPassword {
step = .password
statusMessage = nil
} else if methods.canUseMagicLink {
try await bridge.sendMagicLink(email: email)
try Task.checkCancellation()
magicCode = ""
step = .magicCode
statusMessage = String(format: NSLocalizedString("We sent a sign-in code to %@.", comment: ""), email)
} else {
showError(String(localized: "This email is not available for sign-in."))
}
}
}
func signInWithPassword() {
guard !isLoading else { return }
guard canContinueWithPassword else {
showError(String(localized: "Enter your password."))
return
}
beginLoading(.password, status: String(localized: "Signing in..."))
runAction { [weak self] in
guard let self else { return }
_ = try await bridge.signInWithPassword(email: email, password: password)
try await showSuccessAndFinish()
}
}
func signInWithMagicCode() {
guard !isLoading else { return }
guard canContinueWithCode else {
showError(String(localized: "Enter the 6-digit sign-in code."))
return
}
beginLoading(.magicCode, status: String(localized: "Verifying sign-in code..."))
runAction { [weak self] in
guard let self else { return }
_ = try await bridge.signInWithMagicLink(email: email, token: magicCode)
try await showSuccessAndFinish()
}
}
func resendMagicCode() {
guard !isLoading, canUseMagicLink else { return }
beginLoading(.resendCode, status: String(localized: "Sending a new code..."))
runAction { [weak self] in
guard let self else { return }
try await bridge.sendMagicLink(email: email)
try Task.checkCancellation()
statusMessage = String(format: NSLocalizedString("We sent a new sign-in code to %@.", comment: ""), email)
}
}
func useMagicLink() {
guard canUseMagicLink else { return }
step = .magicCode
resendMagicCode()
}
func openSelfHosted() {
guard !isLoading else { return }
beginLoading(.selfHosted, status: String(localized: "Opening self-hosted sign-in..."))
runAction { [weak self] in
guard let self else { return }
try await bridge.openSelfHostedSignIn()
try Task.checkCancellation()
onFinished?(false)
}
}
private func loadingAction(for provider: NativeOAuthProvider) -> LoadingAction {
switch provider {
case .google:
return .googleOAuth
case .apple:
return .appleOAuth
}
}
private func beginLoading(_ action: LoadingAction, status: String? = nil) {
actionTask?.cancel()
isLoading = true
loadingAction = action
statusMessage = status
errorMessage = nil
isSuccessFeedback = false
}
private func finishLoading() {
isLoading = false
loadingAction = nil
}
private func runAction(_ operation: @escaping @MainActor () async throws -> Void) {
actionTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
try await operation()
try Task.checkCancellation()
} catch is CancellationError {
return
} catch {
showError(error)
}
finishLoading()
}
}
private func showError(_ error: Error) {
showError(error.localizedDescription)
}
private func showError(_ message: String) {
errorMessage = message
statusMessage = nil
isSuccessFeedback = false
}
private func showSuccessAndFinish() async throws {
finishLoading()
errorMessage = nil
isSuccessFeedback = true
statusMessage = String(localized: "Sign in Success")
try await Task.sleep(nanoseconds: 1_150_000_000)
try Task.checkCancellation()
onFinished?(true)
}
private func isValidEmail(_ value: String) -> Bool {
let pattern = #"^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$"#
return value.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
}
}
@@ -0,0 +1,230 @@
import AuthenticationServices
import SwiftUI
import UIKit
import WebKit
enum NativeOAuthProvider: String {
case google = "Google"
case apple = "Apple"
}
final class NativeSignInPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first { $0.isKeyWindow } ?? ASPresentationAnchor()
}
}
@MainActor
final class NativeSignInWebBridge {
struct EmailMethods {
let hasPassword: Bool
let canUseMagicLink: Bool
}
private weak var webView: WKWebView?
private let presentationContextProvider = NativeSignInPresentationContextProvider()
private let pollIntervalNanoseconds: UInt64 = 200_000_000
private var authenticationSession: ASWebAuthenticationSession?
init(webView: WKWebView) {
self.webView = webView
}
func cancel() {
authenticationSession?.cancel()
authenticationSession = nil
}
func getAppearanceSnapshot() async throws -> NativeSignInAppearanceSnapshot {
let result = try await call(
"""
const rawSetting = window.localStorage?.getItem('theme');
const setting = ['system', 'light', 'dark'].includes(rawSetting) ? rawSetting : 'system';
const nativeTheme = typeof window.getCurrentThemeMode === 'function' ? window.getCurrentThemeMode() : undefined;
const dataTheme = document.documentElement.getAttribute('data-theme');
const resolved = setting === 'dark' || setting === 'light'
? setting
: ['light', 'dark'].includes(nativeTheme)
? nativeTheme
: ['light', 'dark'].includes(dataTheme)
? dataTheme
: window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
return { setting, resolved };
""",
arguments: [:]
)
guard let snapshot = result as? [String: Any] else {
throw signInError("Unable to read appearance setting.")
}
let setting = NativeSignInAppearanceSetting(rawValue: snapshot["setting"] as? String ?? "system") ?? .system
let resolvedScheme: ColorScheme = snapshot["resolved"] as? String == "dark" ? .dark : .light
return NativeSignInAppearanceSnapshot(setting: setting, resolvedScheme: resolvedScheme)
}
func startOAuth(provider: NativeOAuthProvider) async throws {
let result = try await call(
"return await window.nativeStartOAuthSignIn(provider);",
arguments: ["provider": provider.rawValue]
)
guard
let urlString = result as? String,
let url = URL(string: urlString)
else {
throw signInError("Unable to start OAuth sign-in.")
}
let callbackURL = try await openAuthenticationSession(url: url)
try await handleAuthenticationCallback(url: callbackURL)
}
private func openAuthenticationSession(url: URL) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "affine") { [weak self] callbackURL, error in
Task { @MainActor in
self?.authenticationSession = nil
if let callbackURL {
continuation.resume(returning: callbackURL)
return
}
if let authError = error as? ASWebAuthenticationSessionError, authError.code == .canceledLogin {
continuation.resume(throwing: self?.signInError("Sign-in was cancelled.") ?? NSError())
return
}
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume(throwing: self?.signInError("Unable to complete OAuth sign-in.") ?? NSError())
}
}
session.presentationContextProvider = presentationContextProvider
session.prefersEphemeralWebBrowserSession = false
authenticationSession = session
if !session.start() {
authenticationSession = nil
continuation.resume(throwing: signInError("Unable to open OAuth sign-in."))
}
}
}
private func handleAuthenticationCallback(url: URL) async throws {
_ = try await call(
"return await window.nativeHandleAuthenticationCallback(url);",
arguments: ["url": url.absoluteString]
)
}
func checkEmailMethods(email: String) async throws -> EmailMethods {
let result = try await call(
"return await window.nativeCheckEmailSignInMethods(email);",
arguments: ["email": email]
)
guard let methods = result as? [String: Any] else {
throw signInError("Unable to check available sign-in methods.")
}
return EmailMethods(
hasPassword: methods["hasPassword"] as? Bool == true,
canUseMagicLink: methods["canUseMagicLink"] as? Bool == true
)
}
func sendMagicLink(email: String) async throws {
_ = try await call(
"return await window.nativeSendEmailMagicLink(email);",
arguments: ["email": email]
)
}
func signInWithMagicLink(email: String, token: String) async throws -> String {
try await signedInIdentifier(
script: "return await window.nativeSignInWithMagicLink(email, token);",
arguments: ["email": email, "token": token]
)
}
func signInWithPassword(email: String, password: String) async throws -> String {
try await signedInIdentifier(
script: "return await window.nativeSignInWithPassword(email, password);",
arguments: ["email": email, "password": password]
)
}
func openSelfHostedSignIn() async throws {
_ = try await call(
"return await window.nativeOpenSelfHostedSignIn();",
arguments: [:]
)
}
func waitForAuthenticated(timeout: TimeInterval = 5 * 60) async throws -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
try Task.checkCancellation()
if let identifier = try await currentUserIdentifier() {
return identifier
}
try await Task.sleep(nanoseconds: pollIntervalNanoseconds)
}
return nil
}
private func signedInIdentifier(script: String, arguments: [String: Any]) async throws -> String {
let result = try await call(script, arguments: arguments)
guard let identifier = userIdentifier(from: result) else {
throw signInError("Unable to complete sign-in.")
}
return identifier
}
private func currentUserIdentifier() async throws -> String? {
let result = try await call(
"return await window.getCurrentUserIdentifier?.();",
arguments: [:]
)
return userIdentifier(from: result)
}
private func call(_ script: String, arguments: [String: Any]) async throws -> Any? {
try Task.checkCancellation()
guard let webView else {
throw signInError("AFFiNE is still loading. Please try again in a moment.")
}
let result = try await webView.callAsyncJavaScript(
script,
arguments: arguments,
contentWorld: .page
)
try Task.checkCancellation()
return result
}
private func userIdentifier(from result: Any?) -> String? {
guard let identifier = result as? String else { return nil }
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func signInError(_ message: String) -> NSError {
NSError(
domain: "NativeSignIn",
code: -1,
userInfo: [NSLocalizedDescriptionKey: NSLocalizedString(message, comment: "")]
)
}
}
@@ -6,6 +6,8 @@
//
import Foundation
import UIKit
import WebKit
extension Optional {
func get(_ failure: String? = nil) throws -> Wrapped {
@@ -19,3 +21,165 @@ extension Optional {
return self
}
}
@MainActor
enum PaywallAuthGuard {
private static let bridgeReadyTimeout: TimeInterval = 10
private static let bridgePollIntervalNanoseconds: UInt64 = 200_000_000
static func ensureSignedIn(
using webView: WKWebView,
dismissing controller: UIViewController? = nil
) async throws -> Bool {
try await waitForNativeSignInBridge(in: webView)
if await currentUserIdentifier(in: webView) != nil {
return true
}
await dismissIfNeeded(controller)
return try await presentNativeSignIn(using: webView)
}
static func currentUserIdentifier(in webView: WKWebView) async -> String? {
do {
let result = try await webView.callAsyncJavaScript(
"return await window.getCurrentUserIdentifier?.();",
contentWorld: .page
)
return userIdentifier(from: result)
} catch {
return nil
}
}
static func hasProSubscription(in webView: WKWebView) async throws -> Bool {
let subscriptionState = try await fetchSubscriptionState(in: webView)
return hasActiveSubscription(subscriptionState["pro"])
}
static func hasAISubscription(in webView: WKWebView) async throws -> Bool {
let subscriptionState = try await fetchSubscriptionState(in: webView)
return hasActiveSubscription(subscriptionState["ai"])
}
private static func hasActiveSubscription(_ value: Any?) -> Bool {
guard let subscription = value as? [String: Any] else { return false }
let status = (subscription["status"] as? String)?.lowercased()
return status == "active" || status == "trialing"
}
private static func fetchSubscriptionState(in webView: WKWebView) async throws -> [String: Any] {
try await waitForBridgeFunctions(["getSubscriptionState"], in: webView)
let result = try await webView.callAsyncJavaScript(
"return await window.getSubscriptionState();",
contentWorld: .page
)
guard let subscriptionState = result as? [String: Any] else {
throw NSError(
domain: "PaywallAuthGuard",
code: -1,
userInfo: [NSLocalizedDescriptionKey: String(localized: "Unable to determine subscription status.")]
)
}
return subscriptionState
}
private static func waitForNativeSignInBridge(in webView: WKWebView) async throws {
try await waitForBridgeFunctions([
"getCurrentUserIdentifier",
"nativeStartOAuthSignIn",
"nativeCheckEmailSignInMethods",
"nativeSendEmailMagicLink",
"nativeSignInWithMagicLink",
"nativeSignInWithPassword",
"nativeOpenSelfHostedSignIn",
], in: webView)
}
private static func presentNativeSignIn(using webView: WKWebView) async throws -> Bool {
guard let presenter = topMostPresenter(from: webView) else {
throw NSError(
domain: "PaywallAuthGuard",
code: -1,
userInfo: [NSLocalizedDescriptionKey: String(localized: "Unable to present sign-in.")]
)
}
guard !(presenter is NativeSignInViewController) else {
return false
}
return await withCheckedContinuation { continuation in
let controller = NativeSignInViewController(webView: webView)
controller.onComplete = { isSignedIn in
continuation.resume(returning: isSignedIn)
}
presenter.present(controller, animated: true)
}
}
private static func topMostPresenter(from webView: WKWebView) -> UIViewController? {
let rootController = webView.window?.rootViewController ?? UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first { $0.isKeyWindow }?
.rootViewController
var presenter = rootController
while let presented = presenter?.presentedViewController {
presenter = presented
}
return presenter
}
private static func waitForBridgeFunctions(_ functionNames: [String], in webView: WKWebView) async throws {
let deadline = Date().addingTimeInterval(bridgeReadyTimeout)
while Date() < deadline {
if await areBridgeFunctionsAvailable(functionNames, in: webView) {
return
}
try await Task.sleep(nanoseconds: bridgePollIntervalNanoseconds)
}
throw NSError(
domain: "PaywallAuthGuard",
code: -1,
userInfo: [NSLocalizedDescriptionKey: String(localized: "AFFiNE is still loading. Please wait a moment and try again.")]
)
}
private static func areBridgeFunctionsAvailable(_ functionNames: [String], in webView: WKWebView) async -> Bool {
let expression = functionNames
.map { "typeof window.\($0) === 'function'" }
.joined(separator: " && ")
do {
let result = try await webView.callAsyncJavaScript(
"return \(expression);",
contentWorld: .page
)
return result as? Bool == true
} catch {
return false
}
}
private static func dismissIfNeeded(_ controller: UIViewController?) async {
guard let controller, controller.presentingViewController != nil else { return }
await withCheckedContinuation { continuation in
controller.dismiss(animated: false) {
continuation.resume()
}
}
}
private static func userIdentifier(from result: Any?) -> String? {
guard let rawIdentifier = result as? String else { return nil }
let identifier = rawIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
return identifier.isEmpty ? nil : identifier
}
}
@@ -0,0 +1,69 @@
import Foundation
public final class AuthAccessTokenCache: @unchecked Sendable {
public static let shared = AuthAccessTokenCache()
private struct Entry {
let token: String
let expiresAt: Date
}
private let lock = NSLock()
private var entries: [String: Entry] = [:]
private init() {}
public func set(_ token: String, expiresAt: Date, for endpoint: String) {
guard let key = Self.canonicalOrigin(endpoint) else { return }
lock.lock()
entries[key] = Entry(token: token, expiresAt: expiresAt)
lock.unlock()
}
public func token(for endpoint: URL, minimumValidity: TimeInterval = 0) -> String? {
token(for: endpoint, matching: nil, minimumValidity: minimumValidity)
}
public func token(
for endpoint: URL,
matching origin: URL?,
minimumValidity: TimeInterval = 0
) -> String? {
guard let key = Self.canonicalOrigin(endpoint) else { return nil }
if let origin, Self.canonicalOrigin(origin) != key { return nil }
lock.lock()
defer { lock.unlock() }
guard let entry = entries[key], entry.expiresAt.timeIntervalSinceNow > minimumValidity else {
return nil
}
return entry.token
}
public func remove(for endpoint: String) {
guard let key = Self.canonicalOrigin(endpoint) else { return }
lock.lock()
entries[key] = nil
lock.unlock()
}
private static func canonicalOrigin(_ endpoint: String) -> String? {
guard let url = URL(string: endpoint) else { return nil }
return canonicalOrigin(url)
}
private static func canonicalOrigin(_ url: URL) -> String? {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased()
else {
return nil
}
let defaultPort = scheme == "https" ? 443 : (scheme == "http" ? 80 : nil)
if let port = components.port, port != defaultPort {
return "\(scheme)://\(host):\(port)"
}
return "\(scheme)://\(host)"
}
}
@@ -16,14 +16,12 @@ let package = Package(
),
],
dependencies: [
.package(path: "../AffineResources"),
.package(url: "https://github.com/RevenueCat/purchases-ios-spm.git", from: "5.83.0"),
],
targets: [
.target(
name: "AffinePaywall",
dependencies: [
"AffineResources",
.product(name: "RevenueCat", package: "purchases-ios-spm"),
]
),
@@ -1,64 +0,0 @@
//
// IntelligentFeatureView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct IntelligentFeatureView: View {
let feature: Feature
struct Feature: Identifiable {
let id: UUID = .init()
let preview: String
let icon: String
let title: String
let features: [String]
}
var body: some View {
VStack(spacing: 24) {
Image(feature.preview, bundle: .module)
.resizable()
.aspectRatio(contentMode: .fit)
HStack(spacing: 8) {
Image(feature.icon, bundle: .module)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
Text(feature.title)
.font(.system(size: 24, weight: .semibold, design: .default))
}
VStack(alignment: .leading, spacing: 12) {
ForEach(feature.features, id: \.self) { item in
HStack(alignment: .firstTextBaseline, spacing: 12) {
Rectangle()
.frame(width: 4, height: 10)
.foregroundStyle(.clear)
.overlay {
Image(systemName: "circle.fill")
.font(.system(size: 4))
.foregroundColor(AffineColors.textSecondary.color)
}
Text(item)
.font(.system(size: 16))
.foregroundColor(AffineColors.textSecondary.color)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
#Preview {
IntelligentFeatureView(
feature: SKUnitIntelligentDetailView.features.first!
)
.padding()
}
@@ -1,36 +0,0 @@
//
// OffsetObservingScrollView.swift
// AffinePaywall
//
// Created by qaq on 9/23/25.
//
import SwiftUI
struct OffsetObservingScrollView<Content: View>: View {
var axes: Axis.Set = [.vertical]
var showsIndicators = true
@Binding var offset: CGPoint
@ViewBuilder var content: () -> Content
private let coordinateSpaceName = UUID()
var body: some View {
ScrollView(axes, showsIndicators: showsIndicators) {
PositionObservingView(
coordinateSpace: .named(coordinateSpaceName),
position: Binding(
get: { offset },
set: { newOffset in
offset = CGPoint(
x: -newOffset.x,
y: -newOffset.y
)
}
),
content: content
)
}
.coordinateSpace(name: coordinateSpaceName)
}
}
@@ -1,37 +0,0 @@
//
// PositionObservingView.swift
// AffinePaywall
//
// Created by qaq on 9/23/25.
//
import SwiftUI
struct PositionObservingView<Content: View>: View {
var coordinateSpace: CoordinateSpace
@Binding var position: CGPoint
@ViewBuilder var content: () -> Content
var body: some View {
content()
.background(GeometryReader { geometry in
Color.clear.preference(
key: PreferenceKey.self,
value: geometry.frame(in: coordinateSpace).origin
)
})
.onPreferenceChange(PreferenceKey.self) { position in
self.position = position
}
}
}
private extension PositionObservingView {
struct PreferenceKey: SwiftUI.PreferenceKey {
static var defaultValue: CGPoint { .zero }
static func reduce(value _: inout CGPoint, nextValue _: () -> CGPoint) {
// No-op
}
}
}
@@ -1,44 +0,0 @@
//
// SKUnitIntelligentDetailView+Feature.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
extension SKUnitIntelligentDetailView {
static let features: [IntelligentFeatureView.Feature] = [
.init(
preview: "AI_PREVIEW_WRITE",
icon: "AI_TEXT",
title: "Write with you",
features: [
"Create quality content from sentences to articles on topics you need",
"Rewrite like the professionals",
"Change the tones / fix spelling & grammar",
]
),
.init(
preview: "AI_PREVIEW_DRAW",
icon: "AI_PEN",
title: "Draw with you",
features: [
"Visualize your mind, magically",
"Turn your outline into beautiful, engaging presentations(Beta)",
"Summarize your content into structured mind-maps",
]
),
.init(
preview: "AI_PREVIEW_PLAN",
icon: "AI_CHECK",
title: "Plan with you",
features: [
"Memorize and tidy up your knowledge",
"Auto-sorting and auto-tagging (Coming soon)",
"Privacy ensured",
]
),
]
}
@@ -1,91 +0,0 @@
//
// SKUnitIntelligentDetailView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct SKUnitIntelligentDetailView: View {
@StateObject var viewModel: ViewModel
@State var detailIndexInSwitching: UUID? = nil
@State var detailIndex: Int = 0 {
didSet { lastInteractionDate = Date() }
}
@State var lastInteractionDate: Date = .init()
@State var scrollOffset: CGPoint = .zero
let timer = Timer
.publish(every: 5, on: .main, in: .common)
.autoconnect()
var body: some View {
VStack(spacing: 24) {
HeadlineView(viewModel: viewModel)
GeometryReader { r in
let height = r.size.height
let width = r.size.width
ScrollViewReader { scrollView in
OffsetObservingScrollView(
axes: .horizontal,
showsIndicators: false,
offset: $scrollOffset
) {
HStack(spacing: 0) {
ForEach(0 ..< Self.features.count, id: \.self) { featureIndex in
let feature = Self.features[featureIndex]
IntelligentFeatureView(feature: feature)
.padding()
.frame(width: width, height: height)
.id(featureIndex)
}
}
}
.frame(height: height)
.onChange(of: detailIndex) { newValue in
withAnimation(.spring) {
scrollView.scrollTo(newValue)
}
}
}
.onChange(of: scrollOffset) { _ in
guard detailIndexInSwitching == nil else { return }
guard width > 0 else { return }
let offset = scrollOffset.x
let newIndex = Int((offset + width / 2) / width)
if newIndex != detailIndex,
(0 ..< Self.features.count).contains(newIndex)
{ detailIndex = newIndex }
}
}
PageDotsView(
current: detailIndex,
total: Self.features.count
) { index in
detailIndex = index
let token = UUID()
detailIndexInSwitching = token
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard detailIndexInSwitching == token else { return }
detailIndexInSwitching = nil
}
}
}
.onReceive(timer) { _ in
if Date().timeIntervalSince(lastInteractionDate) > 5 {
detailIndex = (detailIndex + 1) % Self.features.count
}
}
}
}
#Preview {
SKUnitIntelligentDetailView(viewModel: .vmPreviewForAI)
.padding()
}
@@ -1,69 +0,0 @@
//
// CategorySelectionView.swift
// AffinePaywall
//
// Created by qaq on 9/17/25.
//
import AffineResources
import SwiftUI
struct CategorySelectionView: View {
let selectedTab: SKUnitCategory
let onSelect: (SKUnitCategory) -> Void
var body: some View {
HStack(spacing: 16) {
ForEach(SKUnitCategory.allCases) { tab in
TabItem(type: tab, isSelected: tab == selectedTab)
.onTapGesture { onSelect(tab) }
}
}
.animation(.spring.speed(2), value: selectedTab)
}
struct TabItem: View {
let type: SKUnitCategory
let isSelected: Bool
var font: Font {
if isSelected {
.system(size: 24, weight: .bold)
} else {
.system(size: 24, weight: .regular)
}
}
var color: Color {
if isSelected {
AffineColors.textPrimary.color
} else {
AffineColors.textSecondary.color
}
}
var body: some View {
Text(type.title)
.lineLimit(1)
.font(font)
.foregroundStyle(color)
}
}
}
#Preview {
struct PreviewWrapper: View {
@State var selectedTab: SKUnitCategory = .pro
var body: some View {
CategorySelectionView(selectedTab: selectedTab, onSelect: { selectedTab = $0 })
}
}
return VStack(alignment: .leading, spacing: 12) {
CategorySelectionView(selectedTab: .pro, onSelect: { _ in })
CategorySelectionView(selectedTab: .ai, onSelect: { _ in })
Divider()
PreviewWrapper()
}
.padding()
.background(Color.gray.opacity(0.25).ignoresSafeArea())
}
@@ -1,28 +0,0 @@
//
// HeadlineView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct HeadlineView: View {
@StateObject var viewModel: ViewModel
var body: some View {
VStack(spacing: 8) {
Text(viewModel.selectedUnit.primaryText)
.font(.system(size: 24, weight: .semibold))
.contentTransition(.numericText())
.animation(.spring.speed(2), value: viewModel.category)
.padding(.top, 8)
Text(viewModel.selectedUnit.secondaryText)
.font(.system(size: 16))
.foregroundStyle(AffineColors.textSecondary.color)
.contentTransition(.numericText())
.animation(.spring.speed(2), value: viewModel.category)
}
}
}
@@ -1,134 +0,0 @@
//
// PackageOptionView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct PackageOptionView: View {
let price: String
let description: String
var badge: String
let isSelected: Bool
let action: () -> Void
init(
price: String,
description: String,
badge: String = "",
isSelected: Bool,
action: @escaping () -> Void = {}
) {
self.price = price
self.description = description
self.badge = badge
self.isSelected = isSelected
self.action = action
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(price)
.contentTransition(.numericText())
.font(.system(size: 20, weight: .bold))
.lineLimit(1)
.foregroundColor(isSelected ? AffineColors.buttonPrimary.color : AffineColors.textPrimary.color)
}
.layoutPriority(.infinity)
Spacer(minLength: 0)
if !badge.isEmpty {
Text(badge)
.contentTransition(.numericText())
.font(.system(size: 10))
.bold()
.lineLimit(1)
.foregroundColor(AffineColors.layerPureWhite.color)
.padding(2)
.padding(.horizontal, 2)
.background(AffineColors.buttonPrimary.color)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
if !description.isEmpty {
Text(description)
.contentTransition(.numericText())
.foregroundColor(isSelected ? AffineColors.buttonPrimary.color : AffineColors.textSecondary.color)
.font(.system(size: 14))
}
}
.animation(.interactiveSpring, value: price)
.animation(.interactiveSpring, value: description)
.animation(.interactiveSpring, value: badge)
.padding(12)
.frame(maxWidth: .infinity)
.background {
ZStack {
Rectangle()
.foregroundColor(AffineColors.layerBackgroundPrimary.color)
if isSelected {
Rectangle()
.foregroundColor(AffineColors.buttonPrimary.color)
.opacity(0.05)
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay {
if isSelected {
RoundedRectangle(cornerRadius: 8)
.stroke(AffineColors.buttonPrimary.color, lineWidth: 1.5)
.foregroundColor(.clear)
} else {
RoundedRectangle(cornerRadius: 8)
.stroke(AffineColors.layerBorder.color.opacity(0.15), lineWidth: 1.5)
.foregroundColor(.clear)
}
}
.shadow(color: AffineColors.layerBorder.color.opacity(0.05), radius: 4, x: 0, y: 0)
.animation(.interactiveSpring, value: isSelected)
.contentShape(.rect)
.onTapGesture {
action()
}
}
}
#Preview {
VStack(spacing: 16) {
HStack(spacing: 16) {
PackageOptionView(
price: "$7.99",
description: "Monthly",
isSelected: false
) {}
PackageOptionView(
price: "$6.75",
description: "Annually",
badge: "Save 15%",
isSelected: true
) {}
}
HStack(spacing: 16) {
PackageOptionView(
price: "$114514",
description: "Monthly",
badge: "Most Popular",
isSelected: true
) {}
PackageOptionView(
price: "$6.75",
description: "Annually",
badge: "Save 15%",
isSelected: false
) {}
}
}
.padding(16)
.background(Color.gray.opacity(0.25).ignoresSafeArea())
}
@@ -1,50 +0,0 @@
//
// PageDotsView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct PageDotsView: View {
let current: Int
let total: Int
let onSelection: (Int) -> Void
var body: some View {
HStack(spacing: 8) {
ForEach(0 ..< total, id: \.self) { index in
Circle()
.foregroundStyle(
index == current
? AffineColors.buttonPrimary.color
: AffineColors.textSecondary.color.opacity(0.5)
)
.frame(width: 6, height: 6)
.padding(4)
.contentShape(Rectangle())
.onTapGesture {
onSelection(index)
}
}
}
}
}
#Preview {
VStack(spacing: 32) {
PageDotsView(current: 0, total: 8) { _ in }
PageDotsView(current: 1, total: 8) { _ in }
PageDotsView(current: 2, total: 8) { _ in }
PageDotsView(current: 3, total: 8) { _ in }
PageDotsView(current: 4, total: 8) { _ in }
PageDotsView(current: 5, total: 8) { _ in }
PageDotsView(current: 6, total: 8) { _ in }
PageDotsView(current: 7, total: 8) { _ in }
PageDotsView(current: 8, total: 8) { _ in }
}
.padding()
}
@@ -1,90 +0,0 @@
//
// PurchaseFooterView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct PurchaseFooterView: View {
@StateObject var viewModel: ViewModel
var isPurchased: Bool {
let package = viewModel.selectePackageOption
return viewModel.purchasedItems.contains(package.productIdentifier)
}
var body: some View {
VStack(spacing: 16) {
if viewModel.availablePackageOptions.count > 1 {
HStack(spacing: 8) {
ForEach(viewModel.availablePackageOptions) { option in
PackageOptionView(
price: option.price,
description: option.description,
badge: option.badge ?? "",
isSelected: option.id == viewModel.selectedPackageIdentifier
) {
viewModel.select(packageOption: option)
}
}
}
.disabled(isPurchased)
}
if viewModel.updating {
TheGiveMeMoneyButtonView(
primaryTitle: "Height Placeholder",
secondaryTitle: "",
isPurchased: false
) {}
.hidden()
.background(AffineColors.buttonPrimary.color)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay {
ProgressView()
.progressViewStyle(.circular)
}
.transition(.opacity)
} else {
TheGiveMeMoneyButtonView(
primaryTitle: viewModel.selectePackageOption.primaryTitle,
secondaryTitle: viewModel.selectePackageOption.secondaryTitle,
isPurchased: isPurchased,
callback: viewModel.purchase
)
.transition(.opacity)
}
Button(action: viewModel.restore) {
if isPurchased {
Text("Already Purchased")
} else {
Text("Restore Purchase")
.underline()
}
}
.font(.system(size: 12))
.buttonStyle(.plain)
.foregroundStyle(AffineColors.textSecondary.color)
.opacity(viewModel.products.isEmpty ? 0 : 1)
.disabled(isPurchased)
.disabled(viewModel.updating)
Text("The Monthly and Annual plans renew automatically, but youre free to cancel at any time if its not right for you.")
.font(.system(size: 12))
.foregroundStyle(AffineColors.textSecondary.color)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.animation(.spring, value: viewModel.updating)
}
}
#Preview {
PurchaseFooterView(viewModel: .init())
.padding()
.background(Color.gray.opacity(0.25).ignoresSafeArea())
}
@@ -1,97 +0,0 @@
//
// TheGiveMeMoneyButtonView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct TheGiveMeMoneyButtonView: View {
let primaryTitle: String
let secondaryTitle: String
let isPurchased: Bool
let callback: () -> Void
init(
primaryTitle: String = "",
secondaryTitle: String = "",
isPurchased: Bool,
callback: @escaping () -> Void = {}
) {
self.primaryTitle = primaryTitle
self.secondaryTitle = secondaryTitle
self.isPurchased = isPurchased
self.callback = callback
}
var body: some View {
Button { callback() } label: {
if isPurchased {
Image(systemName: "checkmark")
.foregroundColor(AffineColors.layerPureWhite.color)
.font(.system(size: 16, weight: .bold))
.padding(12)
} else {
HStack(spacing: 4) {
if !primaryTitle.isEmpty {
Text(primaryTitle)
.bold()
.font(.system(size: 16))
.contentTransition(.numericText())
}
if !secondaryTitle.isEmpty {
Text("(\(secondaryTitle))")
.font(.system(size: 12))
.opacity(0.8)
.contentTransition(.numericText())
}
}
.foregroundColor(AffineColors.layerPureWhite.color)
.padding(12)
}
}
.animation(.spring, value: primaryTitle)
.animation(.spring, value: secondaryTitle)
.buttonStyle(.plain)
.frame(maxWidth: .infinity)
.frame(minHeight: 32)
.background(AffineColors.buttonPrimary.color)
.clipShape(RoundedRectangle(cornerRadius: 8))
.disabled(isPurchased)
}
}
// MARK: - Preview
#Preview {
VStack(spacing: 16) {
TheGiveMeMoneyButtonView(
primaryTitle: "Upgrade for $6.75 per month",
secondaryTitle: "",
isPurchased: false
)
TheGiveMeMoneyButtonView(
primaryTitle: "Upgrade for $10 per month",
secondaryTitle: "",
isPurchased: false
)
TheGiveMeMoneyButtonView(
primaryTitle: "$8.9 per month",
secondaryTitle: "billed annually",
isPurchased: false
)
TheGiveMeMoneyButtonView(
primaryTitle: "Upgrade for $499",
secondaryTitle: "",
isPurchased: false
)
TheGiveMeMoneyButtonView(
primaryTitle: "Upgrade for $499",
secondaryTitle: "",
isPurchased: true
)
}
.padding(32)
}
@@ -1,53 +0,0 @@
//
// ProFeatureRowView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct ProFeatureRowView: View {
let feature: Feature
let index: Int
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "checkmark")
.font(.system(size: 16))
.foregroundColor(AffineColors.buttonPrimary.color)
Text(feature.text)
.font(.system(size: 16))
.contentTransition(.numericText())
.foregroundColor(feature.isHighlighted ? AffineColors.buttonPrimary.color : AffineColors.textPrimary.color)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.transition(.opacity)
}
}
#Preview {
VStack(alignment: .leading, spacing: 16) {
Divider()
ProFeatureRowView(
feature: .init(
"Hello World Feature Row View",
isHighlighted: true
),
index: 0
)
Divider()
ProFeatureRowView(
feature: .init("Hello World Feature Row View"),
index: 0
)
Divider()
}
.padding()
.background(Color.gray.opacity(0.25).ignoresSafeArea())
}
@@ -1,103 +0,0 @@
//
// SKUnitProDetailView.swift
// AffinePaywall
//
// Created by qaq on 9/17/25.
//
import AffineResources
import SwiftUI
struct SKUnitProDetailView: View {
@StateObject var viewModel: ViewModel
@State var selection: SKUnitSubcategoryProPlan
@State var headerText: String
@State var features: [Feature]
@State var animationIndex: Int64 = 0
let timer = Timer
.publish(every: 0.075, on: .main, in: .common)
.autoconnect()
init(viewModel: ViewModel) {
_viewModel = .init(wrappedValue: viewModel)
let item = SKUnitSubcategoryProPlan.default
_selection = .init(initialValue: item)
_headerText = .init(initialValue: item.headerText)
_features = .init(initialValue: item.features)
}
var body: some View {
VStack(spacing: 24) {
if SKUnitSubcategoryProPlan.allCases.count > 1 {
Picker("Plan", selection: $selection) {
ForEach(SKUnitSubcategoryProPlan.allCases) { plan in
Text(plan.title).tag(plan)
}
}
.pickerStyle(.segmented)
.onChange(of: selection) { _ in
viewModel.select(subcategory: selection)
}
}
HeadlineView(viewModel: viewModel)
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if !headerText.isEmpty {
Text(headerText)
.font(.system(size: 13))
.foregroundColor(AffineColors.textSecondary.color)
.contentTransition(.numericText())
.transition(.opacity)
.padding(.horizontal, 4)
}
ForEach(Array(features.enumerated()), id: \.element.id) { index, feature in
ProFeatureRowView(feature: feature, index: index)
.opacity(index < animationIndex ? 1 : 0)
}
}
.clipped()
.padding(16)
.background(AffineColors.layerBackgroundPrimary.color)
.cornerRadius(16)
.shadow(color: AffineColors.layerBorder.color.opacity(0.08), radius: 8, y: 2)
.padding(16)
}
.padding(-16)
.animation(.spring.speed(2), value: animationIndex)
.onReceive(timer) { _ in animationIndex += 1 }
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .top
)
.onChange(of: selection) { _ in updateSelectionContents() }
}
}
func updateSelectionContents() {
let headerText = selection.headerText
if self.headerText != headerText {
self.headerText = headerText
}
let features = selection.features
if self.features != features {
self.features = features
}
}
}
#Preview {
SKUnitProDetailView(viewModel: .vmPreviewForPro)
.padding()
.background(
AffineColors.layerBackgroundSecondary
.color
.ignoresSafeArea()
)
.background(Color.gray.opacity(0.25).ignoresSafeArea())
}
@@ -1,19 +0,0 @@
//
// Feature.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
struct Feature: Identifiable, Equatable, Hashable {
var id = UUID()
var text: String
var isHighlighted: Bool // For text like "Everything in AFFINE Pro"
init(_ text: String, isHighlighted: Bool = false) {
self.text = text
self.isHighlighted = isHighlighted
}
}
@@ -1,29 +0,0 @@
//
// SKUnit+AI.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
extension SKUnit {
static let aiUnits: [SKUnit] = [
SKUnit(
category: SKUnitCategory.ai,
primaryText: "AFFINE AI",
secondaryText: "A true multimodal AI copilot.",
package: [
SKUnitPackageOption(
price: "...", // Will be populated from App Store
description: "",
isDefaultSelected: true,
primaryTitle: "...", // Will be populated from App Store
secondaryTitle: "",
productIdentifier: "app.affine.pro.ai.Annual",
revenueCatIdentifier: "app.affine.pro.ai.Annual"
),
]
),
]
}
@@ -1,40 +0,0 @@
//
// SKUnit+Pro.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
extension SKUnit {
static let proUnits: [SKUnit] = [
SKUnit(
category: SKUnitCategory.pro,
subcategory: SKUnitSubcategoryProPlan.default,
primaryText: "Pro",
secondaryText: "For family and small teams.",
package: [
SKUnitPackageOption(
price: "...", // Will be populated from App Store
description: PricingConfiguration.proMonthly.description,
isDefaultSelected: PricingConfiguration.proMonthly.isDefaultSelected,
primaryTitle: "...", // Will be populated from App Store
secondaryTitle: "",
productIdentifier: PricingConfiguration.proMonthly.productIdentifier,
revenueCatIdentifier: PricingConfiguration.proMonthly.revenueCatIdentifier
),
SKUnitPackageOption(
price: "...", // Will be populated from App Store
description: PricingConfiguration.proAnnual.description,
badge: PricingConfiguration.proAnnual.badge,
isDefaultSelected: PricingConfiguration.proAnnual.isDefaultSelected,
primaryTitle: "...", // Will be populated from App Store
secondaryTitle: "",
productIdentifier: PricingConfiguration.proAnnual.productIdentifier,
revenueCatIdentifier: PricingConfiguration.proAnnual.revenueCatIdentifier
),
]
),
]
}
@@ -1,54 +0,0 @@
//
// SKUnit.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
struct SKUnit: Identifiable, Sendable {
let id = UUID()
let category: SKUnitCategory
let subcategory: any SKUnitSubcategorizable
let primaryText: String
let secondaryText: String
let package: [SKUnitPackageOption]
init(
category: SKUnitCategory,
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single,
primaryText: String,
secondaryText: String,
package: [SKUnitPackageOption]
) {
self.category = category
self.subcategory = subcategory
self.primaryText = primaryText
self.secondaryText = secondaryText
self.package = package
}
}
extension SKUnit {
static let allUnits: [SKUnit] = [
proUnits,
aiUnits,
].flatMap(\.self)
static func units(for category: SKUnitCategory) -> [SKUnit] {
allUnits.filter { $0.category == category }
}
static func unit(
for type: SKUnitCategory,
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single
) -> SKUnit? {
let subcategory = subcategory.subcategoryIdentifier
let item = allUnits
.filter { $0.category == type }
.filter { $0.subcategory.subcategoryIdentifier == subcategory }
assert(item.count == 1)
return item.first
}
}
@@ -1,24 +0,0 @@
//
// SKUnitCategory.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
public enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable, Sendable {
public var id: Int { rawValue }
case pro
case ai
}
public extension SKUnitCategory {
var title: String {
switch self {
case .pro: "AFFINE.Pro"
case .ai: "AI"
}
}
}
@@ -1,48 +0,0 @@
//
// SKUnitPackageOption.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
struct SKUnitPackageOption: Identifiable, Equatable {
var id: UUID
// package selection button
var price: String
var description: String
var badge: String?
var isDefaultSelected: Bool
// subscribe button titles
var primaryTitle: String
var secondaryTitle: String
// product identifiers
var productIdentifier: String
var revenueCatIdentifier: String
init(
id: UUID = UUID(),
price: String,
description: String,
badge: String? = nil,
isDefaultSelected: Bool = false,
primaryTitle: String,
secondaryTitle: String,
productIdentifier: String,
revenueCatIdentifier: String
) {
self.id = id
self.price = price
self.description = description
self.badge = badge
self.isDefaultSelected = isDefaultSelected
self.primaryTitle = primaryTitle
self.secondaryTitle = secondaryTitle
self.productIdentifier = productIdentifier
self.revenueCatIdentifier = revenueCatIdentifier
}
}
@@ -1,27 +0,0 @@
//
// SKUnitSubcategorizable.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
protocol SKUnitSubcategorizable: Identifiable, Equatable, Hashable, CaseIterable, Sendable {
var id: String { get }
var subcategoryIdentifier: String { get }
}
extension SKUnitSubcategorizable {
var id: String {
subcategoryIdentifier
}
}
extension SKUnitSubcategorizable where Self: RawRepresentable, Self.RawValue == String {
var subcategoryIdentifier: String { rawValue }
}
enum SKUnitSingleSubcategory: String, SKUnitSubcategorizable {
case single
}
@@ -1,49 +0,0 @@
//
// SKUnitSubcategoryProPlan.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable {
case `default`
var title: String {
switch self {
case .default: "Pro"
}
}
var description: String {
switch self {
case .default:
"For family and small teams."
}
}
}
extension SKUnitSubcategoryProPlan {
var headerText: String {
switch self {
case .default:
"Include in Pro"
}
}
var features: [Feature] {
switch self {
case .default:
[
Feature("Everything in AFFINE FOSS & Basic."),
Feature("100 GB of Cloud Storage"),
Feature("100 MB of Maximum file size"),
Feature("Up to 10 members per Workspace"),
Feature("30-days Cloud Time Machine file version history"),
Feature("Community Support"),
Feature("Real-time Syncing & Collaboration for more people"),
]
}
}
}
@@ -1,62 +0,0 @@
//
// Store.swift
// AffinePaywall
//
// Created by qaq on 9/24/25.
//
import StoreKit
let store = Store.shared
final nonisolated class Store: ObservableObject, Sendable {
static let shared = Store()
private init() {}
func fetchAppStoreContents() async throws {
try await AppStore.sync()
}
func fetchProducts() async throws -> [Product] {
let identifiers = SKUnit.allUnits
.flatMap(\.package)
.map(\.productIdentifier)
print("fetching products for identifiers: \(identifiers)")
#if DEBUG
try await Task.sleep(for: .seconds(1)) // simulate network delay
#endif
let products = try await Product.products(
for: identifiers.map { .init($0) }
)
if products.count != identifiers.count {
throw NSError(domain: "AffinePaywall", code: -1, userInfo: [
NSLocalizedDescriptionKey: String(localized: "Failed to fetch all products from App Store."),
])
}
return products
}
func fetchEntitlements() async throws -> Set<String> {
var purchasedItems: Set<String> = []
for await result in Transaction.currentEntitlements {
if case let .verified(transaction) = result {
guard transaction.revocationDate == nil else { continue }
switch transaction.productType {
case .nonConsumable, .consumable:
purchasedItems.insert(transaction.productID)
case .autoRenewable, .nonRenewable:
if let status = await transaction.subscriptionStatus,
status.state == .subscribed
{ purchasedItems.insert(transaction.productID) }
default:
break
}
}
}
return purchasedItems
}
}
@@ -1,267 +0,0 @@
//
// ViewModel+Action.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
import RevenueCat
import UIKit
extension ViewModel {
func purchase() {
let unit = selectedUnit
let option = selectePackageOption
assert(!updating)
guard !updating else { return }
print(#function, unit, option)
Task.detached {
await MainActor.run { self.updating = true }
var shouldDismiss = false
let product = await self.products.first {
$0.id == option.productIdentifier
}
if let product {
let result = try await product.purchase()
switch result {
case .pending:
break
case let .success(verificationResult):
switch verificationResult {
case .verified(let transaction):
print("purchase success", transaction)
try await self.applySubscription(transactionID: .init(transaction.id))
case .unverified:
#if DEBUG
break
#else
throw NSError() // should not happening
#endif
}
shouldDismiss = true
case .userCancelled:
break
@unknown default:
assertionFailure()
}
} else { assertionFailure() } // should never happen
await MainActor.run {
self.updating = false
}
if shouldDismiss {
await MainActor.run {
self.dismiss()
}
}
}
}
func restore() {
let unit = selectedUnit
let option = selectePackageOption
assert(!updating)
guard !updating else { return }
print(#function, unit, option)
Task.detached {
// before we continue, sync any changes from App Store
// this will ask user to sign in if needed
do {
try await store.fetchAppStoreContents()
} catch {
// ignore user's cancellation on restore, not a huge deal
print("updateAppStoreItems error:", error)
}
await MainActor.run { self.updateAppStoreStatus(initial: false) }
}
}
func dismiss() {
print(#function)
if let context = associatedWebContext {
Task.detached {
do {
_ = try await context.callAsyncJavaScript(
"return await window.updateSubscriptionState();",
contentWorld: .page
)
print("updateSubscriptionState success")
} catch {
print("updateSubscriptionState error:", error.localizedDescription)
}
}
}
associatedController?.dismiss(animated: true)
}
func applySubscription(transactionID: String) async throws {
print(#function, transactionID)
if let context = associatedWebContext {
_ = try await context.callAsyncJavaScript(
"return await window.requestApplySubscription('\(transactionID)');",
contentWorld: .page
)
print("requestApplySubscription success")
} else {
assertionFailure()
throw NSError()
}
}
}
nonisolated extension ViewModel {
func updateAppStoreStatusExecute(initial: Bool) async {
guard await !updating else { return }
guard let controller = await associatedController else { return }
await MainActor.run { self.updating = true }
do {
// now we fetch records from app store
let products = try await store.fetchProducts()
await MainActor.run {
self.products = products
self.updatePackageOptions(with: products)
}
// fetch purchased items if signed in
do {
let purchase = try await store.fetchEntitlements()
await MainActor.run { self.storePurchasedItems = purchase }
} catch {
print("fetchEntitlements error:", error)
if !initial { throw error }
}
guard let webView = await associatedWebContext else {
throw NSError(domain: "Paywall", code: -1, userInfo: [
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
])
}
// fetch current user identifier
do {
let result = try await webView.callAsyncJavaScript(
"return await window.getCurrentUserIdentifier();",
contentWorld: .page
)
let userIdentifier = (result as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// for too long it might be a problem on front end returning what we dont want
guard !userIdentifier.isEmpty, userIdentifier.count < 256 else {
throw NSError(domain: "Paywall", code: -1, userInfo: [
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
])
}
print("[*] using user identifier:", userIdentifier)
let configuration = Configuration
.builder(withAPIKey: Paywall.revenueCatToken)
.with(appUserID: userIdentifier)
.with(showStoreMessagesAutomatically: false)
.build()
Purchases.configure(with: configuration)
_ = try? await Purchases.shared.logOut()
let loginItem = try await Purchases.shared.logIn(userIdentifier)
print("[*] log in to RevenueCat finished: \(loginItem)")
} catch {
print("unable to login with error:", error.localizedDescription)
throw error
}
// fetch external items by executing on webview's JS context
do {
let result = try await webView.callAsyncJavaScript(
"return await window.getSubscriptionState();",
contentWorld: .page
)
let purchased = decodeWebContextSubscriptionInformation(result)
print("fetched external purchased items:", purchased)
await MainActor.run { self.externalPurchasedItems = purchased }
} catch {
print("fetchExternalEntitlements error:", error.localizedDescription)
throw error
}
// select the package under purchased items if any
let availablePackages = await availablePackageOptions
let purchase = await purchasedItems
let purchasedPackages = availablePackages.filter {
purchase.contains($0.productIdentifier)
}
assert(purchasedPackages.count <= 1)
if let firstPurchased = purchasedPackages.first {
await MainActor.run {
self.select(packageOption: firstPurchased)
}
}
} catch {
await MainActor.run {
let alert = UIAlertController(
title: String(localized: "Error"),
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(
UIAlertAction(
title: String(localized: "OK"),
style: .default
) { [self] _ in dismiss() }
)
controller.present(alert, animated: true)
}
}
await MainActor.run { self.updating = false }
}
nonisolated func decodeWebContextSubscriptionInformation(_ input: Any?) -> Set<String> {
var ans: Set<String> = []
guard let dict = input as? [String: Any] else {
assertionFailure()
return ans
}
let pro = dict["pro"] as? [String: Any]
let ai = dict["ai"] as? [String: Any]
if let proPlan = pro?["recurring"] as? String {
switch proPlan.lowercased() {
case "lifetime":
// user actually purchased believer plan
// but we map it to yearly plan just for easier handling
// do not purchase any of this plan if already purchased
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
case "monthly":
ans.insert(PricingConfiguration.proMonthly.productIdentifier)
case "yearly":
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
default:
ans.insert(PricingConfiguration.proAnnual.productIdentifier) // block payment
assertionFailure()
}
}
if let aiPlan = ai?["recurring"] as? String {
switch aiPlan.lowercased() {
case "yearly":
ans.insert(PricingConfiguration.aiAnnual.productIdentifier)
default:
// ai plan can only be purchased as yearly plan
ans.insert(PricingConfiguration.aiAnnual.productIdentifier) // block payment
assertionFailure()
}
}
return ans
}
}
@@ -1,23 +0,0 @@
//
// ViewModel+Preview.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import Foundation
extension ViewModel {
static let vmPreviewForPro: ViewModel = {
let vm = ViewModel()
vm.select(category: .pro)
vm.select(subcategory: SKUnitSubcategoryProPlan.default)
return vm
}()
static let vmPreviewForAI: ViewModel = {
let vm = ViewModel()
vm.select(category: .ai)
return vm
}()
}
@@ -1,214 +0,0 @@
//
// ViewModel.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import StoreKit
import SwiftUI
import WebKit
@MainActor
class ViewModel: ObservableObject {
var availableUnits: [SKUnit] {
SKUnit.units(for: category)
}
@Published private(set) var category: SKUnitCategory = .pro
@Published private(set) var subcategory: any SKUnitSubcategorizable = SKUnitSubcategoryProPlan.default
@Published private(set) var selectedPackageIdentifier: UUID = SKUnit.unit(
for: .pro,
subcategory: SKUnitSubcategoryProPlan.default
)!.package.first { $0.isDefaultSelected }!.id
@Published var updating = false
@Published var products: [Product] = []
@Published var storePurchasedItems: Set<String> = []
@Published var externalPurchasedItems: Set<String> = []
@Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package)
var purchasedItems: Set<String> {
Set<String>()
.union(storePurchasedItems)
.union(externalPurchasedItems)
}
private(set) weak var associatedController: UIViewController?
private(set) weak var associatedWebContext: WKWebView?
init() {
updateAppStoreStatus(initial: true)
}
func updateAppStoreStatus(initial: Bool) {
Task.detached {
await self.updateAppStoreStatusExecute(initial: initial)
}
}
func bind(controller: UIViewController) {
associatedController = controller
}
func bind(context: WKWebView) {
associatedWebContext = context
}
func select(category: SKUnitCategory) {
self.category = category
let units = SKUnit.units(for: category)
let subcategoryExists = units
.contains { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }
if !subcategoryExists {
subcategory = units.first!.subcategory
}
_ = selectePackageOption // ensure selectePackageOption is valid
}
func select(subcategory: any SKUnitSubcategorizable) {
let units = SKUnit.units(for: category)
let subcategoryExists = units
.contains { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }
if !subcategoryExists {
let category = availableUnits
.first { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }!
.category
self.category = category
} else {
self.subcategory = subcategory
}
_ = selectePackageOption // ensure selectePackageOption is valid
}
func select(packageOption option: SKUnitPackageOption) {
selectedPackageIdentifier = option.id
let unit = availableUnits
.first { unit in
unit.package.contains { $0.id == option.id }
}!
category = unit.category
subcategory = unit.subcategory
_ = selectePackageOption // ensure selectePackageOption is valid
}
func updatePackageOptions(with products: [Product]) {
var updatedOptions = packageOptions
for (index, option) in updatedOptions.enumerated() {
if let product = products.first(where: { $0.id == option.productIdentifier }) {
let price = product.displayPrice
let description = product.description
let (purchasePrimaryTitle, purchaseSecondaryTitle) = purchaseButtonText(
for: product,
option: option
)
updatedOptions[index] = SKUnitPackageOption(
id: option.id,
price: price,
description: option.description.isEmpty ? description : option.description,
badge: option.badge,
isDefaultSelected: option.isDefaultSelected,
primaryTitle: purchasePrimaryTitle,
secondaryTitle: purchaseSecondaryTitle,
productIdentifier: option.productIdentifier,
revenueCatIdentifier: option.revenueCatIdentifier
)
}
}
packageOptions = updatedOptions
}
private func purchaseButtonText(for product: Product, option: SKUnitPackageOption) -> (String, String) {
let monthlyPrice = calculateMonthlyPrice(for: product, option: option)
if option.productIdentifier.contains(".ai.") {
return ("\(monthlyPrice) per month", "billed annually")
} else {
return ("Upgrade for \(monthlyPrice) per month", "")
}
}
private func calculateMonthlyPrice(for product: Product, option _: SKUnitPackageOption) -> String {
guard let subscription = product.subscription else {
preconditionFailure("Product must have subscription information")
}
switch subscription.subscriptionPeriod.unit {
case .year:
let yearlyPrice = product.price
let monthlyPrice = yearlyPrice / 12.0
// Round up to ensure total price is slightly lower than yearly price
var roundedMonthlyPrice = monthlyPrice
var rounded = Decimal()
NSDecimalRound(&rounded, &roundedMonthlyPrice, 2, .up)
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = product.priceFormatStyle.currencyCode
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
if let formattedMonthlyPrice = formatter.string(from: NSDecimalNumber(decimal: rounded)) {
return formattedMonthlyPrice
}
case .month:
return product.displayPrice
case .week, .day:
preconditionFailure("Unsupported subscription period: \(subscription.subscriptionPeriod.unit)")
@unknown default:
preconditionFailure("Unknown subscription period")
}
return product.displayPrice
}
}
@MainActor
extension ViewModel {
var selectedUnit: SKUnit {
if let unit = SKUnit.unit(for: category, subcategory: subcategory) {
return unit
}
let units = SKUnit.units(for: category)
if let last = units.last {
subcategory = last.subcategory
return last
}
let item = availableUnits.first!
category = item.category
subcategory = item.subcategory
return item
}
var selectePackageOption: SKUnitPackageOption {
let unitPackageIds = selectedUnit.package.map(\.id)
let item = packageOptions
.first { $0.id == selectedPackageIdentifier && unitPackageIds.contains($0.id) }
if let item { return item }
let defaultItem = packageOptions
.first { $0.isDefaultSelected && unitPackageIds.contains($0.id) }
if let defaultItem {
selectedPackageIdentifier = defaultItem.id
return defaultItem
}
let lastItem = packageOptions
.first { unitPackageIds.contains($0.id) }!
selectedPackageIdentifier = lastItem.id
return lastItem
}
var availablePackageOptions: [SKUnitPackageOption] {
let unitPackageIds = selectedUnit.package.map(\.id)
return packageOptions.filter { unitPackageIds.contains($0.id) }
}
}
@@ -0,0 +1,256 @@
import Foundation
import RevenueCat
import SwiftUI
import WebKit
public enum NativePaywallPlanKind: String, CaseIterable, Sendable {
case lite
case pro
case ai
}
public struct NativePaywallPriceInfo: Equatable, Sendable {
public let value: String
public let suffix: String
public init(value: String, suffix: String) {
self.value = value
self.suffix = suffix
}
}
@MainActor
public final class NativePaywallBridge: ObservableObject {
@Published public private(set) var isLoading = false
@Published public private(set) var isProcessing = false
@Published public private(set) var priceInfoByPlan: [NativePaywallPlanKind: NativePaywallPriceInfo]
@Published public private(set) var selectedPlan: NativePaywallPlanKind
@Published public var errorMessage: String?
private weak var associatedWebContext: WKWebView?
private var productsByPlan: [NativePaywallPlanKind: StoreProduct] = [:]
public init(initialPlan: NativePaywallPlanKind = .pro) {
selectedPlan = initialPlan
priceInfoByPlan = [:]
}
public func bind(webView: WKWebView?) {
associatedWebContext = webView
}
public func selectPlan(_ plan: NativePaywallPlanKind) {
selectedPlan = plan
}
public var isReady: Bool {
productsByPlan.count == NativePaywallPlanKind.allCases.count
}
public func priceInfo(for plan: NativePaywallPlanKind) -> NativePaywallPriceInfo? {
priceInfoByPlan[plan]
}
public func prepare() async throws {
isLoading = true
defer { isLoading = false }
guard associatedWebContext != nil else {
throw bridgeError("Missing required information")
}
configurePurchases()
let products = await Purchases.shared.products(Self.productIdentifiers)
try Task.checkCancellation()
guard products.count == Self.productIdentifiers.count else {
throw bridgeError("Unable to load subscription options right now.")
}
var nextProducts: [NativePaywallPlanKind: StoreProduct] = [:]
for plan in NativePaywallPlanKind.allCases {
if let product = products.first(where: { $0.productIdentifier == Self.productIdentifier(for: plan) }) {
nextProducts[plan] = product
}
}
guard nextProducts.count == NativePaywallPlanKind.allCases.count else {
throw bridgeError("Unable to load subscription options right now.")
}
productsByPlan = nextProducts
var nextPriceInfo: [NativePaywallPlanKind: NativePaywallPriceInfo] = [:]
for plan in NativePaywallPlanKind.allCases {
if let product = nextProducts[plan] {
nextPriceInfo[plan] = Self.makePriceInfo(for: plan, product: product)
}
}
priceInfoByPlan = nextPriceInfo
}
public func purchaseSelectedPlan() async throws -> Bool {
isProcessing = true
defer { isProcessing = false }
guard let webView = associatedWebContext else {
throw bridgeError("Missing required information")
}
try await configurePurchasesForCurrentUser(in: webView)
guard let product = productsByPlan[selectedPlan] else {
throw bridgeError("Unable to load the selected plan.")
}
let result = try await Purchases.shared.purchase(product: product)
if result.userCancelled {
return false
}
try Task.checkCancellation()
if let transaction = result.transaction {
try await applySubscription(transactionID: transaction.transactionIdentifier, in: webView)
} else {
try await updateSubscriptionState(in: webView)
}
return true
}
public func restorePurchases() async throws {
isProcessing = true
defer { isProcessing = false }
guard let webView = associatedWebContext else {
throw bridgeError("Missing required information")
}
try await configurePurchasesForCurrentUser(in: webView)
_ = try await Purchases.shared.restorePurchases()
try await updateSubscriptionState(in: webView)
}
}
private extension NativePaywallBridge {
static let revenueCatToken = "appl_FIzFhieVpSSmJRYJWwhVrgtnsVf"
static let revenueCatProxyEndpoint = URL(string: "https://iap.affine.pro/")!
static var isPurchasesConfigured = false
static let productIdentifiers: [String] = [
PricingConfiguration.proMonthly.productIdentifier,
PricingConfiguration.proAnnual.productIdentifier,
PricingConfiguration.aiAnnual.productIdentifier,
]
static func productIdentifier(for plan: NativePaywallPlanKind) -> String {
switch plan {
case .lite:
PricingConfiguration.proMonthly.productIdentifier
case .pro:
PricingConfiguration.proAnnual.productIdentifier
case .ai:
PricingConfiguration.aiAnnual.productIdentifier
}
}
func configurePurchases() {
#if DEBUG
Purchases.logLevel = .debug
#endif
Purchases.proxyURL = Self.revenueCatProxyEndpoint
if !Self.isPurchasesConfigured {
let configuration = Configuration
.builder(withAPIKey: Self.revenueCatToken)
.with(showStoreMessagesAutomatically: false)
.build()
Purchases.configure(with: configuration)
Self.isPurchasesConfigured = true
}
}
func configurePurchasesForCurrentUser(in webView: WKWebView) async throws {
configurePurchases()
let userIdentifier = try await fetchCurrentUserIdentifier(in: webView)
_ = try await Purchases.shared.logIn(userIdentifier)
}
func fetchCurrentUserIdentifier(in webView: WKWebView) async throws -> String {
let result = try await webView.callAsyncJavaScript(
"return await window.getCurrentUserIdentifier();",
contentWorld: .page
)
let userIdentifier = (result as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !userIdentifier.isEmpty, userIdentifier.count < 256 else {
throw bridgeError("Missing required information")
}
return userIdentifier
}
func applySubscription(transactionID: String, in webView: WKWebView) async throws {
_ = try await webView.callAsyncJavaScript(
"return await window.requestApplySubscription(transactionID);",
arguments: ["transactionID": transactionID],
contentWorld: .page
)
}
func updateSubscriptionState(in webView: WKWebView) async throws {
_ = try await webView.callAsyncJavaScript(
"return await window.updateSubscriptionState();",
contentWorld: .page
)
}
static func makePriceInfo(for plan: NativePaywallPlanKind, product: StoreProduct) -> NativePaywallPriceInfo {
switch plan {
case .lite:
return NativePaywallPriceInfo(value: product.localizedPriceString, suffix: "/month")
case .pro:
return NativePaywallPriceInfo(value: product.localizedPriceString, suffix: "/year")
case .ai:
return NativePaywallPriceInfo(
value: monthlyEquivalentPrice(for: product) ?? product.localizedPriceString,
suffix: "/mo, billed annually"
)
}
}
static func monthlyEquivalentPrice(for product: StoreProduct) -> String? {
guard let subscriptionPeriod = product.subscriptionPeriod else {
return nil
}
let months: Int
switch subscriptionPeriod.unit {
case .month:
months = max(subscriptionPeriod.value, 1)
case .year:
months = max(subscriptionPeriod.value * 12, 12)
case .week, .day:
return nil
@unknown default:
return nil
}
var monthlyPrice = product.price / Decimal(months)
var rounded = Decimal()
NSDecimalRound(&rounded, &monthlyPrice, 2, .plain)
guard let formatter = product.priceFormatter?.copy() as? NumberFormatter else {
return product.localizedPriceString
}
return formatter.string(from: rounded as NSDecimalNumber)
}
func bridgeError(_ message: String) -> NSError {
NSError(
domain: "NativePaywallBridge",
code: -1,
userInfo: [NSLocalizedDescriptionKey: NSLocalizedString(message, comment: "")]
)
}
}
@@ -1,96 +0,0 @@
//
// AffinePaywallPageView.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import AffineResources
import SwiftUI
struct AffinePaywallPageView: View {
@StateObject var viewModel = ViewModel()
@Environment(\.dismiss) var dismiss
@State private var presentAnimation = false
@State private var showCloseButton = false
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
CategorySelectionView(
selectedTab: viewModel.category,
onSelect: viewModel.select(category:)
)
Spacer()
Button {
viewModel.dismiss()
} label: {
AffineIcons.close.image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
}
.buttonStyle(.plain)
.foregroundColor(AffineColors.textSecondary.color)
.opacity(showCloseButton ? 1 : 0)
.disabled(!showCloseButton)
.animation(.spring, value: showCloseButton)
}
ZStack(alignment: .topLeading) {
Spacer()
.frame(maxWidth: .infinity, maxHeight: .infinity)
content
.frame(maxWidth: .infinity)
.transition(
.opacity
.combined(with: .scale(
scale: 0.95,
anchor: .init(x: 0.5, y: 0)
))
)
}
.animation(.spring.speed(2), value: viewModel.category)
PurchaseFooterView(viewModel: viewModel)
.animation(.spring.speed(2), value: viewModel.selectedPackageIdentifier)
}
.padding()
.opacity(presentAnimation ? 1 : 0)
.scaleEffect(presentAnimation ? 1 : 0.95, anchor: .top)
.animation(.spring, value: presentAnimation)
.onAppear {
presentAnimation = true
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
showCloseButton = true
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
AffineColors.layerBackgroundSecondary.color
.ignoresSafeArea()
)
}
@ViewBuilder
var content: some View {
switch viewModel.category {
case .pro:
SKUnitProDetailView(viewModel: viewModel)
case .ai:
SKUnitIntelligentDetailView(viewModel: viewModel)
}
}
}
#Preview {
struct PreviewWrapper: View {
@StateObject var viewModel = ViewModel()
var body: some View {
AffinePaywallPageView(viewModel: viewModel)
}
}
return PreviewWrapper()
}
@@ -1,57 +0,0 @@
//
// Paywall.swift
// AffinePaywall
//
// Created by qaq on 9/18/25.
//
import RevenueCat
import SwiftUI
import UIKit
import WebKit
public enum Paywall {
package static let revenueCatToken: String = "appl_FIzFhieVpSSmJRYJWwhVrgtnsVf"
package static let revenueCatProxyEndpoit = URL(string: "https://iap.affine.pro/")!
package static var isPurchasesConfigured = false
private static let setupExecution: Void = {
#if DEBUG
Purchases.logLevel = .debug
#endif
Purchases.proxyURL = revenueCatProxyEndpoit
return ()
}()
nonisolated
public static func setup() {
_ = setupExecution
}
@MainActor
public static func presentWall(
toController controller: UIViewController,
bindWebContext context: WKWebView?,
type: String
) {
let viewModel = ViewModel()
if let context { viewModel.bind(context: context) }
switch type.lowercased() {
case "pro":
viewModel.select(category: .pro)
viewModel.select(subcategory: SKUnitSubcategoryProPlan.default)
case "ai":
viewModel.select(category: .ai)
viewModel.select(subcategory: SKUnitSingleSubcategory.single)
default:
break
}
let view = AffinePaywallPageView(viewModel: viewModel)
let hostingController = UIHostingController(rootView: view)
viewModel.bind(controller: hostingController)
hostingController.modalPresentationStyle = .overFullScreen
hostingController.modalTransitionStyle = .coverVertical
hostingController.preferredContentSize = CGSize(width: 555, height: 555) // for iPads
controller.present(hostingController, animated: true)
}
}
@@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 3.25C4.48122 3.25 3.25 4.48122 3.25 6V18C3.25 19.5188 4.48122 20.75 6 20.75H18C19.5188 20.75 20.75 19.5188 20.75 18V6C20.75 4.48122 19.5188 3.25 18 3.25H6ZM4.75 6C4.75 5.30964 5.30964 4.75 6 4.75H18C18.6904 4.75 19.25 5.30964 19.25 6V18C19.25 18.6904 18.6904 19.25 18 19.25H6C5.30964 19.25 4.75 18.6904 4.75 18V6ZM16.5303 9.53033C16.8232 9.23744 16.8232 8.76256 16.5303 8.46967C16.2374 8.17678 15.7626 8.17678 15.4697 8.46967L10.5 13.4393L9.03033 11.9697C8.73744 11.6768 8.26256 11.6768 7.96967 11.9697C7.67678 12.2626 7.67678 12.7374 7.96967 13.0303L9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L16.5303 9.53033Z" fill="#1E96EB"/>
</svg>

Before

Width:  |  Height:  |  Size: 806 B

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

Before

Width:  |  Height:  |  Size: 697 B

@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "AI_PEN.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,22 +0,0 @@
{
"images" : [
{
"filename" : "AI_PREVIEW_B.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "AI_PREVIEW_B 1.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,22 +0,0 @@
{
"images" : [
{
"filename" : "AI_PREVIEW_C.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "AI_PREVIEW_C 1.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,22 +0,0 @@
{
"images" : [
{
"filename" : "AI_PREVIEW_A 1.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "AI_PREVIEW_A.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,4 +0,0 @@
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.75 4C3.75 3.58579 4.08579 3.25 4.5 3.25H20.5C20.9142 3.25 21.25 3.58579 21.25 4V6.66667C21.25 7.08088 20.9142 7.41667 20.5 7.41667C20.0858 7.41667 19.75 7.08088 19.75 6.66667V4.75H13.25V19.25H16.5C16.9142 19.25 17.25 19.5858 17.25 20C17.25 20.4142 16.9142 20.75 16.5 20.75H8.5C8.08579 20.75 7.75 20.4142 7.75 20C7.75 19.5858 8.08579 19.25 8.5 19.25H11.75V4.75H5.25V6.66667C5.25 7.08088 4.91421 7.41667 4.5 7.41667C4.08579 7.41667 3.75 7.08088 3.75 6.66667V4Z" fill="#1E96EB"/>
</svg>

Before

Width:  |  Height:  |  Size: 633 B

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

Some files were not shown because too many files have changed in this diff Show More