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>
@@ -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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 799 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 687 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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 you’re free to cancel at any time if it’s not right for you.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AffineColors.textSecondary.color)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.animation(.spring, value: viewModel.updating)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PurchaseFooterView(viewModel: .init())
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.25).ignoresSafeArea())
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
//
|
||||
// TheGiveMeMoneyButtonView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct TheGiveMeMoneyButtonView: View {
|
||||
let primaryTitle: String
|
||||
let secondaryTitle: String
|
||||
let isPurchased: Bool
|
||||
let callback: () -> Void
|
||||
|
||||
init(
|
||||
primaryTitle: String = "",
|
||||
secondaryTitle: String = "",
|
||||
isPurchased: Bool,
|
||||
callback: @escaping () -> Void = {}
|
||||
) {
|
||||
self.primaryTitle = primaryTitle
|
||||
self.secondaryTitle = secondaryTitle
|
||||
self.isPurchased = isPurchased
|
||||
self.callback = callback
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button { callback() } label: {
|
||||
if isPurchased {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(AffineColors.layerPureWhite.color)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.padding(12)
|
||||
} else {
|
||||
HStack(spacing: 4) {
|
||||
if !primaryTitle.isEmpty {
|
||||
Text(primaryTitle)
|
||||
.bold()
|
||||
.font(.system(size: 16))
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
if !secondaryTitle.isEmpty {
|
||||
Text("(\(secondaryTitle))")
|
||||
.font(.system(size: 12))
|
||||
.opacity(0.8)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
.foregroundColor(AffineColors.layerPureWhite.color)
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
.animation(.spring, value: primaryTitle)
|
||||
.animation(.spring, value: secondaryTitle)
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 32)
|
||||
.background(AffineColors.buttonPrimary.color)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.disabled(isPurchased)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 16) {
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $6.75 per month",
|
||||
secondaryTitle: "",
|
||||
isPurchased: false
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $10 per month",
|
||||
secondaryTitle: "",
|
||||
isPurchased: false
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "$8.9 per month",
|
||||
secondaryTitle: "billed annually",
|
||||
isPurchased: false
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $499",
|
||||
secondaryTitle: "",
|
||||
isPurchased: false
|
||||
)
|
||||
TheGiveMeMoneyButtonView(
|
||||
primaryTitle: "Upgrade for $499",
|
||||
secondaryTitle: "",
|
||||
isPurchased: true
|
||||
)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
//
|
||||
// ProFeatureRowView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct ProFeatureRowView: View {
|
||||
let feature: Feature
|
||||
let index: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(AffineColors.buttonPrimary.color)
|
||||
|
||||
Text(feature.text)
|
||||
.font(.system(size: 16))
|
||||
.contentTransition(.numericText())
|
||||
.foregroundColor(feature.isHighlighted ? AffineColors.buttonPrimary.color : AffineColors.textPrimary.color)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Divider()
|
||||
ProFeatureRowView(
|
||||
feature: .init(
|
||||
"Hello World Feature Row View",
|
||||
isHighlighted: true
|
||||
),
|
||||
index: 0
|
||||
)
|
||||
Divider()
|
||||
ProFeatureRowView(
|
||||
feature: .init("Hello World Feature Row View"),
|
||||
index: 0
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.25).ignoresSafeArea())
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
//
|
||||
// SKUnitProDetailView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/17/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct SKUnitProDetailView: View {
|
||||
@StateObject var viewModel: ViewModel
|
||||
|
||||
@State var selection: SKUnitSubcategoryProPlan
|
||||
@State var headerText: String
|
||||
@State var features: [Feature]
|
||||
@State var animationIndex: Int64 = 0
|
||||
|
||||
let timer = Timer
|
||||
.publish(every: 0.075, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
|
||||
init(viewModel: ViewModel) {
|
||||
_viewModel = .init(wrappedValue: viewModel)
|
||||
let item = SKUnitSubcategoryProPlan.default
|
||||
_selection = .init(initialValue: item)
|
||||
_headerText = .init(initialValue: item.headerText)
|
||||
_features = .init(initialValue: item.features)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
if SKUnitSubcategoryProPlan.allCases.count > 1 {
|
||||
Picker("Plan", selection: $selection) {
|
||||
ForEach(SKUnitSubcategoryProPlan.allCases) { plan in
|
||||
Text(plan.title).tag(plan)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: selection) { _ in
|
||||
viewModel.select(subcategory: selection)
|
||||
}
|
||||
}
|
||||
|
||||
HeadlineView(viewModel: viewModel)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if !headerText.isEmpty {
|
||||
Text(headerText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(AffineColors.textSecondary.color)
|
||||
.contentTransition(.numericText())
|
||||
.transition(.opacity)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
ForEach(Array(features.enumerated()), id: \.element.id) { index, feature in
|
||||
ProFeatureRowView(feature: feature, index: index)
|
||||
.opacity(index < animationIndex ? 1 : 0)
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
.padding(16)
|
||||
.background(AffineColors.layerBackgroundPrimary.color)
|
||||
.cornerRadius(16)
|
||||
.shadow(color: AffineColors.layerBorder.color.opacity(0.08), radius: 8, y: 2)
|
||||
.padding(16)
|
||||
}
|
||||
.padding(-16)
|
||||
.animation(.spring.speed(2), value: animationIndex)
|
||||
.onReceive(timer) { _ in animationIndex += 1 }
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: .top
|
||||
)
|
||||
.onChange(of: selection) { _ in updateSelectionContents() }
|
||||
}
|
||||
}
|
||||
|
||||
func updateSelectionContents() {
|
||||
let headerText = selection.headerText
|
||||
if self.headerText != headerText {
|
||||
self.headerText = headerText
|
||||
}
|
||||
let features = selection.features
|
||||
if self.features != features {
|
||||
self.features = features
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SKUnitProDetailView(viewModel: .vmPreviewForPro)
|
||||
.padding()
|
||||
.background(
|
||||
AffineColors.layerBackgroundSecondary
|
||||
.color
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
.background(Color.gray.opacity(0.25).ignoresSafeArea())
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//
|
||||
// Feature.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Feature: Identifiable, Equatable, Hashable {
|
||||
var id = UUID()
|
||||
var text: String
|
||||
var isHighlighted: Bool // For text like "Everything in AFFINE Pro"
|
||||
|
||||
init(_ text: String, isHighlighted: Bool = false) {
|
||||
self.text = text
|
||||
self.isHighlighted = isHighlighted
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
//
|
||||
// SKUnit+AI.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension SKUnit {
|
||||
static let aiUnits: [SKUnit] = [
|
||||
SKUnit(
|
||||
category: SKUnitCategory.ai,
|
||||
primaryText: "AFFINE AI",
|
||||
secondaryText: "A true multimodal AI copilot.",
|
||||
package: [
|
||||
SKUnitPackageOption(
|
||||
price: "...", // Will be populated from App Store
|
||||
description: "",
|
||||
isDefaultSelected: true,
|
||||
primaryTitle: "...", // Will be populated from App Store
|
||||
secondaryTitle: "",
|
||||
productIdentifier: "app.affine.pro.ai.Annual",
|
||||
revenueCatIdentifier: "app.affine.pro.ai.Annual"
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//
|
||||
// SKUnit+Pro.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension SKUnit {
|
||||
static let proUnits: [SKUnit] = [
|
||||
SKUnit(
|
||||
category: SKUnitCategory.pro,
|
||||
subcategory: SKUnitSubcategoryProPlan.default,
|
||||
primaryText: "Pro",
|
||||
secondaryText: "For family and small teams.",
|
||||
package: [
|
||||
SKUnitPackageOption(
|
||||
price: "...", // Will be populated from App Store
|
||||
description: PricingConfiguration.proMonthly.description,
|
||||
isDefaultSelected: PricingConfiguration.proMonthly.isDefaultSelected,
|
||||
primaryTitle: "...", // Will be populated from App Store
|
||||
secondaryTitle: "",
|
||||
productIdentifier: PricingConfiguration.proMonthly.productIdentifier,
|
||||
revenueCatIdentifier: PricingConfiguration.proMonthly.revenueCatIdentifier
|
||||
),
|
||||
SKUnitPackageOption(
|
||||
price: "...", // Will be populated from App Store
|
||||
description: PricingConfiguration.proAnnual.description,
|
||||
badge: PricingConfiguration.proAnnual.badge,
|
||||
isDefaultSelected: PricingConfiguration.proAnnual.isDefaultSelected,
|
||||
primaryTitle: "...", // Will be populated from App Store
|
||||
secondaryTitle: "",
|
||||
productIdentifier: PricingConfiguration.proAnnual.productIdentifier,
|
||||
revenueCatIdentifier: PricingConfiguration.proAnnual.revenueCatIdentifier
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
//
|
||||
// SKUnit.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SKUnit: Identifiable, Sendable {
|
||||
let id = UUID()
|
||||
let category: SKUnitCategory
|
||||
let subcategory: any SKUnitSubcategorizable
|
||||
let primaryText: String
|
||||
let secondaryText: String
|
||||
let package: [SKUnitPackageOption]
|
||||
|
||||
init(
|
||||
category: SKUnitCategory,
|
||||
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single,
|
||||
primaryText: String,
|
||||
secondaryText: String,
|
||||
package: [SKUnitPackageOption]
|
||||
) {
|
||||
self.category = category
|
||||
self.subcategory = subcategory
|
||||
self.primaryText = primaryText
|
||||
self.secondaryText = secondaryText
|
||||
self.package = package
|
||||
}
|
||||
}
|
||||
|
||||
extension SKUnit {
|
||||
static let allUnits: [SKUnit] = [
|
||||
proUnits,
|
||||
aiUnits,
|
||||
].flatMap(\.self)
|
||||
|
||||
static func units(for category: SKUnitCategory) -> [SKUnit] {
|
||||
allUnits.filter { $0.category == category }
|
||||
}
|
||||
|
||||
static func unit(
|
||||
for type: SKUnitCategory,
|
||||
subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single
|
||||
) -> SKUnit? {
|
||||
let subcategory = subcategory.subcategoryIdentifier
|
||||
let item = allUnits
|
||||
.filter { $0.category == type }
|
||||
.filter { $0.subcategory.subcategoryIdentifier == subcategory }
|
||||
assert(item.count == 1)
|
||||
return item.first
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//
|
||||
// SKUnitCategory.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable, Sendable {
|
||||
public var id: Int { rawValue }
|
||||
|
||||
case pro
|
||||
case ai
|
||||
}
|
||||
|
||||
public extension SKUnitCategory {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pro: "AFFINE.Pro"
|
||||
case .ai: "AI"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//
|
||||
// SKUnitPackageOption.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SKUnitPackageOption: Identifiable, Equatable {
|
||||
var id: UUID
|
||||
|
||||
// package selection button
|
||||
var price: String
|
||||
var description: String
|
||||
var badge: String?
|
||||
var isDefaultSelected: Bool
|
||||
|
||||
// subscribe button titles
|
||||
var primaryTitle: String
|
||||
var secondaryTitle: String
|
||||
|
||||
// product identifiers
|
||||
var productIdentifier: String
|
||||
var revenueCatIdentifier: String
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
price: String,
|
||||
description: String,
|
||||
badge: String? = nil,
|
||||
isDefaultSelected: Bool = false,
|
||||
primaryTitle: String,
|
||||
secondaryTitle: String,
|
||||
productIdentifier: String,
|
||||
revenueCatIdentifier: String
|
||||
) {
|
||||
self.id = id
|
||||
self.price = price
|
||||
self.description = description
|
||||
self.badge = badge
|
||||
self.isDefaultSelected = isDefaultSelected
|
||||
self.primaryTitle = primaryTitle
|
||||
self.secondaryTitle = secondaryTitle
|
||||
self.productIdentifier = productIdentifier
|
||||
self.revenueCatIdentifier = revenueCatIdentifier
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//
|
||||
// SKUnitSubcategorizable.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol SKUnitSubcategorizable: Identifiable, Equatable, Hashable, CaseIterable, Sendable {
|
||||
var id: String { get }
|
||||
var subcategoryIdentifier: String { get }
|
||||
}
|
||||
|
||||
extension SKUnitSubcategorizable {
|
||||
var id: String {
|
||||
subcategoryIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
extension SKUnitSubcategorizable where Self: RawRepresentable, Self.RawValue == String {
|
||||
var subcategoryIdentifier: String { rawValue }
|
||||
}
|
||||
|
||||
enum SKUnitSingleSubcategory: String, SKUnitSubcategorizable {
|
||||
case single
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//
|
||||
// SKUnitSubcategoryProPlan.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable {
|
||||
case `default`
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .default: "Pro"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .default:
|
||||
"For family and small teams."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SKUnitSubcategoryProPlan {
|
||||
var headerText: String {
|
||||
switch self {
|
||||
case .default:
|
||||
"Include in Pro"
|
||||
}
|
||||
}
|
||||
|
||||
var features: [Feature] {
|
||||
switch self {
|
||||
case .default:
|
||||
[
|
||||
Feature("Everything in AFFINE FOSS & Basic."),
|
||||
Feature("100 GB of Cloud Storage"),
|
||||
Feature("100 MB of Maximum file size"),
|
||||
Feature("Up to 10 members per Workspace"),
|
||||
Feature("30-days Cloud Time Machine file version history"),
|
||||
Feature("Community Support"),
|
||||
Feature("Real-time Syncing & Collaboration for more people"),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//
|
||||
// Store.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/24/25.
|
||||
//
|
||||
|
||||
import StoreKit
|
||||
|
||||
let store = Store.shared
|
||||
|
||||
final nonisolated class Store: ObservableObject, Sendable {
|
||||
static let shared = Store()
|
||||
|
||||
private init() {}
|
||||
|
||||
func fetchAppStoreContents() async throws {
|
||||
try await AppStore.sync()
|
||||
}
|
||||
|
||||
func fetchProducts() async throws -> [Product] {
|
||||
let identifiers = SKUnit.allUnits
|
||||
.flatMap(\.package)
|
||||
.map(\.productIdentifier)
|
||||
print("fetching products for identifiers: \(identifiers)")
|
||||
#if DEBUG
|
||||
try await Task.sleep(for: .seconds(1)) // simulate network delay
|
||||
#endif
|
||||
let products = try await Product.products(
|
||||
for: identifiers.map { .init($0) }
|
||||
)
|
||||
if products.count != identifiers.count {
|
||||
throw NSError(domain: "AffinePaywall", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: String(localized: "Failed to fetch all products from App Store."),
|
||||
])
|
||||
}
|
||||
return products
|
||||
}
|
||||
|
||||
func fetchEntitlements() async throws -> Set<String> {
|
||||
var purchasedItems: Set<String> = []
|
||||
|
||||
for await result in Transaction.currentEntitlements {
|
||||
if case let .verified(transaction) = result {
|
||||
guard transaction.revocationDate == nil else { continue }
|
||||
|
||||
switch transaction.productType {
|
||||
case .nonConsumable, .consumable:
|
||||
purchasedItems.insert(transaction.productID)
|
||||
case .autoRenewable, .nonRenewable:
|
||||
if let status = await transaction.subscriptionStatus,
|
||||
status.state == .subscribed
|
||||
{ purchasedItems.insert(transaction.productID) }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return purchasedItems
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
//
|
||||
// ViewModel+Action.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RevenueCat
|
||||
import UIKit
|
||||
|
||||
extension ViewModel {
|
||||
func purchase() {
|
||||
let unit = selectedUnit
|
||||
let option = selectePackageOption
|
||||
|
||||
assert(!updating)
|
||||
guard !updating else { return }
|
||||
print(#function, unit, option)
|
||||
|
||||
Task.detached {
|
||||
await MainActor.run { self.updating = true }
|
||||
var shouldDismiss = false
|
||||
|
||||
let product = await self.products.first {
|
||||
$0.id == option.productIdentifier
|
||||
}
|
||||
|
||||
if let product {
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .pending:
|
||||
break
|
||||
case let .success(verificationResult):
|
||||
switch verificationResult {
|
||||
case .verified(let transaction):
|
||||
print("purchase success", transaction)
|
||||
try await self.applySubscription(transactionID: .init(transaction.id))
|
||||
case .unverified:
|
||||
#if DEBUG
|
||||
break
|
||||
#else
|
||||
throw NSError() // should not happening
|
||||
#endif
|
||||
}
|
||||
shouldDismiss = true
|
||||
case .userCancelled:
|
||||
break
|
||||
@unknown default:
|
||||
assertionFailure()
|
||||
}
|
||||
} else { assertionFailure() } // should never happen
|
||||
|
||||
await MainActor.run {
|
||||
self.updating = false
|
||||
}
|
||||
if shouldDismiss {
|
||||
await MainActor.run {
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restore() {
|
||||
let unit = selectedUnit
|
||||
let option = selectePackageOption
|
||||
|
||||
assert(!updating)
|
||||
guard !updating else { return }
|
||||
print(#function, unit, option)
|
||||
|
||||
Task.detached {
|
||||
// before we continue, sync any changes from App Store
|
||||
// this will ask user to sign in if needed
|
||||
do {
|
||||
try await store.fetchAppStoreContents()
|
||||
} catch {
|
||||
// ignore user's cancellation on restore, not a huge deal
|
||||
print("updateAppStoreItems error:", error)
|
||||
}
|
||||
|
||||
await MainActor.run { self.updateAppStoreStatus(initial: false) }
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
print(#function)
|
||||
|
||||
if let context = associatedWebContext {
|
||||
Task.detached {
|
||||
do {
|
||||
_ = try await context.callAsyncJavaScript(
|
||||
"return await window.updateSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
print("updateSubscriptionState success")
|
||||
} catch {
|
||||
print("updateSubscriptionState error:", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
associatedController?.dismiss(animated: true)
|
||||
}
|
||||
|
||||
func applySubscription(transactionID: String) async throws {
|
||||
print(#function, transactionID)
|
||||
|
||||
if let context = associatedWebContext {
|
||||
_ = try await context.callAsyncJavaScript(
|
||||
"return await window.requestApplySubscription('\(transactionID)');",
|
||||
contentWorld: .page
|
||||
)
|
||||
print("requestApplySubscription success")
|
||||
} else {
|
||||
assertionFailure()
|
||||
throw NSError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated extension ViewModel {
|
||||
func updateAppStoreStatusExecute(initial: Bool) async {
|
||||
guard await !updating else { return }
|
||||
guard let controller = await associatedController else { return }
|
||||
await MainActor.run { self.updating = true }
|
||||
|
||||
do {
|
||||
// now we fetch records from app store
|
||||
let products = try await store.fetchProducts()
|
||||
await MainActor.run {
|
||||
self.products = products
|
||||
self.updatePackageOptions(with: products)
|
||||
}
|
||||
|
||||
// fetch purchased items if signed in
|
||||
do {
|
||||
let purchase = try await store.fetchEntitlements()
|
||||
await MainActor.run { self.storePurchasedItems = purchase }
|
||||
} catch {
|
||||
print("fetchEntitlements error:", error)
|
||||
if !initial { throw error }
|
||||
}
|
||||
|
||||
guard let webView = await associatedWebContext else {
|
||||
throw NSError(domain: "Paywall", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
|
||||
])
|
||||
}
|
||||
|
||||
// fetch current user identifier
|
||||
do {
|
||||
let result = try await webView.callAsyncJavaScript(
|
||||
"return await window.getCurrentUserIdentifier();",
|
||||
contentWorld: .page
|
||||
)
|
||||
let userIdentifier = (result as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
// for too long it might be a problem on front end returning what we dont want
|
||||
guard !userIdentifier.isEmpty, userIdentifier.count < 256 else {
|
||||
throw NSError(domain: "Paywall", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
|
||||
])
|
||||
}
|
||||
print("[*] using user identifier:", userIdentifier)
|
||||
let configuration = Configuration
|
||||
.builder(withAPIKey: Paywall.revenueCatToken)
|
||||
.with(appUserID: userIdentifier)
|
||||
.with(showStoreMessagesAutomatically: false)
|
||||
.build()
|
||||
Purchases.configure(with: configuration)
|
||||
_ = try? await Purchases.shared.logOut()
|
||||
let loginItem = try await Purchases.shared.logIn(userIdentifier)
|
||||
print("[*] log in to RevenueCat finished: \(loginItem)")
|
||||
} catch {
|
||||
print("unable to login with error:", error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
|
||||
// fetch external items by executing on webview's JS context
|
||||
do {
|
||||
let result = try await webView.callAsyncJavaScript(
|
||||
"return await window.getSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
let purchased = decodeWebContextSubscriptionInformation(result)
|
||||
print("fetched external purchased items:", purchased)
|
||||
await MainActor.run { self.externalPurchasedItems = purchased }
|
||||
} catch {
|
||||
print("fetchExternalEntitlements error:", error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
|
||||
// select the package under purchased items if any
|
||||
let availablePackages = await availablePackageOptions
|
||||
let purchase = await purchasedItems
|
||||
let purchasedPackages = availablePackages.filter {
|
||||
purchase.contains($0.productIdentifier)
|
||||
}
|
||||
assert(purchasedPackages.count <= 1)
|
||||
if let firstPurchased = purchasedPackages.first {
|
||||
await MainActor.run {
|
||||
self.select(packageOption: firstPurchased)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
let alert = UIAlertController(
|
||||
title: String(localized: "Error"),
|
||||
message: error.localizedDescription,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(
|
||||
UIAlertAction(
|
||||
title: String(localized: "OK"),
|
||||
style: .default
|
||||
) { [self] _ in dismiss() }
|
||||
)
|
||||
controller.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run { self.updating = false }
|
||||
}
|
||||
|
||||
nonisolated func decodeWebContextSubscriptionInformation(_ input: Any?) -> Set<String> {
|
||||
var ans: Set<String> = []
|
||||
|
||||
guard let dict = input as? [String: Any] else {
|
||||
assertionFailure()
|
||||
return ans
|
||||
}
|
||||
|
||||
let pro = dict["pro"] as? [String: Any]
|
||||
let ai = dict["ai"] as? [String: Any]
|
||||
|
||||
if let proPlan = pro?["recurring"] as? String {
|
||||
switch proPlan.lowercased() {
|
||||
case "lifetime":
|
||||
// user actually purchased believer plan
|
||||
// but we map it to yearly plan just for easier handling
|
||||
// do not purchase any of this plan if already purchased
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
|
||||
case "monthly":
|
||||
ans.insert(PricingConfiguration.proMonthly.productIdentifier)
|
||||
case "yearly":
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
|
||||
default:
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier) // block payment
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
if let aiPlan = ai?["recurring"] as? String {
|
||||
switch aiPlan.lowercased() {
|
||||
case "yearly":
|
||||
ans.insert(PricingConfiguration.aiAnnual.productIdentifier)
|
||||
default:
|
||||
// ai plan can only be purchased as yearly plan
|
||||
ans.insert(PricingConfiguration.aiAnnual.productIdentifier) // block payment
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
return ans
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// ViewModel+Preview.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension ViewModel {
|
||||
static let vmPreviewForPro: ViewModel = {
|
||||
let vm = ViewModel()
|
||||
vm.select(category: .pro)
|
||||
vm.select(subcategory: SKUnitSubcategoryProPlan.default)
|
||||
return vm
|
||||
}()
|
||||
|
||||
static let vmPreviewForAI: ViewModel = {
|
||||
let vm = ViewModel()
|
||||
vm.select(category: .ai)
|
||||
return vm
|
||||
}()
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
//
|
||||
// ViewModel.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
class ViewModel: ObservableObject {
|
||||
var availableUnits: [SKUnit] {
|
||||
SKUnit.units(for: category)
|
||||
}
|
||||
|
||||
@Published private(set) var category: SKUnitCategory = .pro
|
||||
@Published private(set) var subcategory: any SKUnitSubcategorizable = SKUnitSubcategoryProPlan.default
|
||||
@Published private(set) var selectedPackageIdentifier: UUID = SKUnit.unit(
|
||||
for: .pro,
|
||||
subcategory: SKUnitSubcategoryProPlan.default
|
||||
)!.package.first { $0.isDefaultSelected }!.id
|
||||
|
||||
@Published var updating = false
|
||||
@Published var products: [Product] = []
|
||||
@Published var storePurchasedItems: Set<String> = []
|
||||
@Published var externalPurchasedItems: Set<String> = []
|
||||
@Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package)
|
||||
|
||||
var purchasedItems: Set<String> {
|
||||
Set<String>()
|
||||
.union(storePurchasedItems)
|
||||
.union(externalPurchasedItems)
|
||||
}
|
||||
|
||||
private(set) weak var associatedController: UIViewController?
|
||||
private(set) weak var associatedWebContext: WKWebView?
|
||||
|
||||
init() {
|
||||
updateAppStoreStatus(initial: true)
|
||||
}
|
||||
|
||||
func updateAppStoreStatus(initial: Bool) {
|
||||
Task.detached {
|
||||
await self.updateAppStoreStatusExecute(initial: initial)
|
||||
}
|
||||
}
|
||||
|
||||
func bind(controller: UIViewController) {
|
||||
associatedController = controller
|
||||
}
|
||||
|
||||
func bind(context: WKWebView) {
|
||||
associatedWebContext = context
|
||||
}
|
||||
|
||||
func select(category: SKUnitCategory) {
|
||||
self.category = category
|
||||
let units = SKUnit.units(for: category)
|
||||
let subcategoryExists = units
|
||||
.contains { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }
|
||||
if !subcategoryExists {
|
||||
subcategory = units.first!.subcategory
|
||||
}
|
||||
_ = selectePackageOption // ensure selectePackageOption is valid
|
||||
}
|
||||
|
||||
func select(subcategory: any SKUnitSubcategorizable) {
|
||||
let units = SKUnit.units(for: category)
|
||||
let subcategoryExists = units
|
||||
.contains { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }
|
||||
if !subcategoryExists {
|
||||
let category = availableUnits
|
||||
.first { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }!
|
||||
.category
|
||||
self.category = category
|
||||
} else {
|
||||
self.subcategory = subcategory
|
||||
}
|
||||
_ = selectePackageOption // ensure selectePackageOption is valid
|
||||
}
|
||||
|
||||
func select(packageOption option: SKUnitPackageOption) {
|
||||
selectedPackageIdentifier = option.id
|
||||
|
||||
let unit = availableUnits
|
||||
.first { unit in
|
||||
unit.package.contains { $0.id == option.id }
|
||||
}!
|
||||
category = unit.category
|
||||
subcategory = unit.subcategory
|
||||
|
||||
_ = selectePackageOption // ensure selectePackageOption is valid
|
||||
}
|
||||
|
||||
func updatePackageOptions(with products: [Product]) {
|
||||
var updatedOptions = packageOptions
|
||||
|
||||
for (index, option) in updatedOptions.enumerated() {
|
||||
if let product = products.first(where: { $0.id == option.productIdentifier }) {
|
||||
let price = product.displayPrice
|
||||
let description = product.description
|
||||
|
||||
let (purchasePrimaryTitle, purchaseSecondaryTitle) = purchaseButtonText(
|
||||
for: product,
|
||||
option: option
|
||||
)
|
||||
|
||||
updatedOptions[index] = SKUnitPackageOption(
|
||||
id: option.id,
|
||||
price: price,
|
||||
description: option.description.isEmpty ? description : option.description,
|
||||
badge: option.badge,
|
||||
isDefaultSelected: option.isDefaultSelected,
|
||||
primaryTitle: purchasePrimaryTitle,
|
||||
secondaryTitle: purchaseSecondaryTitle,
|
||||
productIdentifier: option.productIdentifier,
|
||||
revenueCatIdentifier: option.revenueCatIdentifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
packageOptions = updatedOptions
|
||||
}
|
||||
|
||||
private func purchaseButtonText(for product: Product, option: SKUnitPackageOption) -> (String, String) {
|
||||
let monthlyPrice = calculateMonthlyPrice(for: product, option: option)
|
||||
|
||||
if option.productIdentifier.contains(".ai.") {
|
||||
return ("\(monthlyPrice) per month", "billed annually")
|
||||
} else {
|
||||
return ("Upgrade for \(monthlyPrice) per month", "")
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateMonthlyPrice(for product: Product, option _: SKUnitPackageOption) -> String {
|
||||
guard let subscription = product.subscription else {
|
||||
preconditionFailure("Product must have subscription information")
|
||||
}
|
||||
|
||||
switch subscription.subscriptionPeriod.unit {
|
||||
case .year:
|
||||
let yearlyPrice = product.price
|
||||
let monthlyPrice = yearlyPrice / 12.0
|
||||
|
||||
// Round up to ensure total price is slightly lower than yearly price
|
||||
var roundedMonthlyPrice = monthlyPrice
|
||||
var rounded = Decimal()
|
||||
NSDecimalRound(&rounded, &roundedMonthlyPrice, 2, .up)
|
||||
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = product.priceFormatStyle.currencyCode
|
||||
formatter.minimumFractionDigits = 2
|
||||
formatter.maximumFractionDigits = 2
|
||||
|
||||
if let formattedMonthlyPrice = formatter.string(from: NSDecimalNumber(decimal: rounded)) {
|
||||
return formattedMonthlyPrice
|
||||
}
|
||||
|
||||
case .month:
|
||||
return product.displayPrice
|
||||
|
||||
case .week, .day:
|
||||
preconditionFailure("Unsupported subscription period: \(subscription.subscriptionPeriod.unit)")
|
||||
|
||||
@unknown default:
|
||||
preconditionFailure("Unknown subscription period")
|
||||
}
|
||||
|
||||
return product.displayPrice
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension ViewModel {
|
||||
var selectedUnit: SKUnit {
|
||||
if let unit = SKUnit.unit(for: category, subcategory: subcategory) {
|
||||
return unit
|
||||
}
|
||||
let units = SKUnit.units(for: category)
|
||||
if let last = units.last {
|
||||
subcategory = last.subcategory
|
||||
return last
|
||||
}
|
||||
let item = availableUnits.first!
|
||||
category = item.category
|
||||
subcategory = item.subcategory
|
||||
return item
|
||||
}
|
||||
|
||||
var selectePackageOption: SKUnitPackageOption {
|
||||
let unitPackageIds = selectedUnit.package.map(\.id)
|
||||
let item = packageOptions
|
||||
.first { $0.id == selectedPackageIdentifier && unitPackageIds.contains($0.id) }
|
||||
if let item { return item }
|
||||
let defaultItem = packageOptions
|
||||
.first { $0.isDefaultSelected && unitPackageIds.contains($0.id) }
|
||||
if let defaultItem {
|
||||
selectedPackageIdentifier = defaultItem.id
|
||||
return defaultItem
|
||||
}
|
||||
let lastItem = packageOptions
|
||||
.first { unitPackageIds.contains($0.id) }!
|
||||
selectedPackageIdentifier = lastItem.id
|
||||
return lastItem
|
||||
}
|
||||
|
||||
var availablePackageOptions: [SKUnitPackageOption] {
|
||||
let unitPackageIds = selectedUnit.package.map(\.id)
|
||||
return packageOptions.filter { unitPackageIds.contains($0.id) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
import RevenueCat
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
public enum NativePaywallPlanKind: String, CaseIterable, Sendable {
|
||||
case lite
|
||||
case pro
|
||||
case ai
|
||||
}
|
||||
|
||||
public struct NativePaywallPriceInfo: Equatable, Sendable {
|
||||
public let value: String
|
||||
public let suffix: String
|
||||
|
||||
public init(value: String, suffix: String) {
|
||||
self.value = value
|
||||
self.suffix = suffix
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class NativePaywallBridge: ObservableObject {
|
||||
@Published public private(set) var isLoading = false
|
||||
@Published public private(set) var isProcessing = false
|
||||
@Published public private(set) var priceInfoByPlan: [NativePaywallPlanKind: NativePaywallPriceInfo]
|
||||
@Published public private(set) var selectedPlan: NativePaywallPlanKind
|
||||
@Published public var errorMessage: String?
|
||||
|
||||
private weak var associatedWebContext: WKWebView?
|
||||
private var productsByPlan: [NativePaywallPlanKind: StoreProduct] = [:]
|
||||
|
||||
public init(initialPlan: NativePaywallPlanKind = .pro) {
|
||||
selectedPlan = initialPlan
|
||||
priceInfoByPlan = [:]
|
||||
}
|
||||
|
||||
public func bind(webView: WKWebView?) {
|
||||
associatedWebContext = webView
|
||||
}
|
||||
|
||||
public func selectPlan(_ plan: NativePaywallPlanKind) {
|
||||
selectedPlan = plan
|
||||
}
|
||||
|
||||
public var isReady: Bool {
|
||||
productsByPlan.count == NativePaywallPlanKind.allCases.count
|
||||
}
|
||||
|
||||
public func priceInfo(for plan: NativePaywallPlanKind) -> NativePaywallPriceInfo? {
|
||||
priceInfoByPlan[plan]
|
||||
}
|
||||
|
||||
public func prepare() async throws {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
guard associatedWebContext != nil else {
|
||||
throw bridgeError("Missing required information")
|
||||
}
|
||||
configurePurchases()
|
||||
|
||||
let products = await Purchases.shared.products(Self.productIdentifiers)
|
||||
try Task.checkCancellation()
|
||||
guard products.count == Self.productIdentifiers.count else {
|
||||
throw bridgeError("Unable to load subscription options right now.")
|
||||
}
|
||||
|
||||
var nextProducts: [NativePaywallPlanKind: StoreProduct] = [:]
|
||||
for plan in NativePaywallPlanKind.allCases {
|
||||
if let product = products.first(where: { $0.productIdentifier == Self.productIdentifier(for: plan) }) {
|
||||
nextProducts[plan] = product
|
||||
}
|
||||
}
|
||||
|
||||
guard nextProducts.count == NativePaywallPlanKind.allCases.count else {
|
||||
throw bridgeError("Unable to load subscription options right now.")
|
||||
}
|
||||
|
||||
productsByPlan = nextProducts
|
||||
|
||||
var nextPriceInfo: [NativePaywallPlanKind: NativePaywallPriceInfo] = [:]
|
||||
for plan in NativePaywallPlanKind.allCases {
|
||||
if let product = nextProducts[plan] {
|
||||
nextPriceInfo[plan] = Self.makePriceInfo(for: plan, product: product)
|
||||
}
|
||||
}
|
||||
priceInfoByPlan = nextPriceInfo
|
||||
}
|
||||
|
||||
public func purchaseSelectedPlan() async throws -> Bool {
|
||||
isProcessing = true
|
||||
defer { isProcessing = false }
|
||||
|
||||
guard let webView = associatedWebContext else {
|
||||
throw bridgeError("Missing required information")
|
||||
}
|
||||
try await configurePurchasesForCurrentUser(in: webView)
|
||||
|
||||
guard let product = productsByPlan[selectedPlan] else {
|
||||
throw bridgeError("Unable to load the selected plan.")
|
||||
}
|
||||
|
||||
let result = try await Purchases.shared.purchase(product: product)
|
||||
if result.userCancelled {
|
||||
return false
|
||||
}
|
||||
|
||||
try Task.checkCancellation()
|
||||
|
||||
if let transaction = result.transaction {
|
||||
try await applySubscription(transactionID: transaction.transactionIdentifier, in: webView)
|
||||
} else {
|
||||
try await updateSubscriptionState(in: webView)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
public func restorePurchases() async throws {
|
||||
isProcessing = true
|
||||
defer { isProcessing = false }
|
||||
|
||||
guard let webView = associatedWebContext else {
|
||||
throw bridgeError("Missing required information")
|
||||
}
|
||||
try await configurePurchasesForCurrentUser(in: webView)
|
||||
_ = try await Purchases.shared.restorePurchases()
|
||||
try await updateSubscriptionState(in: webView)
|
||||
}
|
||||
}
|
||||
|
||||
private extension NativePaywallBridge {
|
||||
static let revenueCatToken = "appl_FIzFhieVpSSmJRYJWwhVrgtnsVf"
|
||||
static let revenueCatProxyEndpoint = URL(string: "https://iap.affine.pro/")!
|
||||
static var isPurchasesConfigured = false
|
||||
|
||||
static let productIdentifiers: [String] = [
|
||||
PricingConfiguration.proMonthly.productIdentifier,
|
||||
PricingConfiguration.proAnnual.productIdentifier,
|
||||
PricingConfiguration.aiAnnual.productIdentifier,
|
||||
]
|
||||
|
||||
static func productIdentifier(for plan: NativePaywallPlanKind) -> String {
|
||||
switch plan {
|
||||
case .lite:
|
||||
PricingConfiguration.proMonthly.productIdentifier
|
||||
case .pro:
|
||||
PricingConfiguration.proAnnual.productIdentifier
|
||||
case .ai:
|
||||
PricingConfiguration.aiAnnual.productIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
func configurePurchases() {
|
||||
#if DEBUG
|
||||
Purchases.logLevel = .debug
|
||||
#endif
|
||||
Purchases.proxyURL = Self.revenueCatProxyEndpoint
|
||||
|
||||
if !Self.isPurchasesConfigured {
|
||||
let configuration = Configuration
|
||||
.builder(withAPIKey: Self.revenueCatToken)
|
||||
.with(showStoreMessagesAutomatically: false)
|
||||
.build()
|
||||
Purchases.configure(with: configuration)
|
||||
Self.isPurchasesConfigured = true
|
||||
}
|
||||
}
|
||||
|
||||
func configurePurchasesForCurrentUser(in webView: WKWebView) async throws {
|
||||
configurePurchases()
|
||||
let userIdentifier = try await fetchCurrentUserIdentifier(in: webView)
|
||||
_ = try await Purchases.shared.logIn(userIdentifier)
|
||||
}
|
||||
|
||||
func fetchCurrentUserIdentifier(in webView: WKWebView) async throws -> String {
|
||||
let result = try await webView.callAsyncJavaScript(
|
||||
"return await window.getCurrentUserIdentifier();",
|
||||
contentWorld: .page
|
||||
)
|
||||
let userIdentifier = (result as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
guard !userIdentifier.isEmpty, userIdentifier.count < 256 else {
|
||||
throw bridgeError("Missing required information")
|
||||
}
|
||||
|
||||
return userIdentifier
|
||||
}
|
||||
|
||||
func applySubscription(transactionID: String, in webView: WKWebView) async throws {
|
||||
_ = try await webView.callAsyncJavaScript(
|
||||
"return await window.requestApplySubscription(transactionID);",
|
||||
arguments: ["transactionID": transactionID],
|
||||
contentWorld: .page
|
||||
)
|
||||
}
|
||||
|
||||
func updateSubscriptionState(in webView: WKWebView) async throws {
|
||||
_ = try await webView.callAsyncJavaScript(
|
||||
"return await window.updateSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
}
|
||||
|
||||
static func makePriceInfo(for plan: NativePaywallPlanKind, product: StoreProduct) -> NativePaywallPriceInfo {
|
||||
switch plan {
|
||||
case .lite:
|
||||
return NativePaywallPriceInfo(value: product.localizedPriceString, suffix: "/month")
|
||||
case .pro:
|
||||
return NativePaywallPriceInfo(value: product.localizedPriceString, suffix: "/year")
|
||||
case .ai:
|
||||
return NativePaywallPriceInfo(
|
||||
value: monthlyEquivalentPrice(for: product) ?? product.localizedPriceString,
|
||||
suffix: "/mo, billed annually"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func monthlyEquivalentPrice(for product: StoreProduct) -> String? {
|
||||
guard let subscriptionPeriod = product.subscriptionPeriod else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let months: Int
|
||||
switch subscriptionPeriod.unit {
|
||||
case .month:
|
||||
months = max(subscriptionPeriod.value, 1)
|
||||
case .year:
|
||||
months = max(subscriptionPeriod.value * 12, 12)
|
||||
case .week, .day:
|
||||
return nil
|
||||
@unknown default:
|
||||
return nil
|
||||
}
|
||||
|
||||
var monthlyPrice = product.price / Decimal(months)
|
||||
var rounded = Decimal()
|
||||
NSDecimalRound(&rounded, &monthlyPrice, 2, .plain)
|
||||
|
||||
guard let formatter = product.priceFormatter?.copy() as? NumberFormatter else {
|
||||
return product.localizedPriceString
|
||||
}
|
||||
|
||||
return formatter.string(from: rounded as NSDecimalNumber)
|
||||
}
|
||||
|
||||
func bridgeError(_ message: String) -> NSError {
|
||||
NSError(
|
||||
domain: "NativePaywallBridge",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: NSLocalizedString(message, comment: "")]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//
|
||||
// AffinePaywallPageView.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import AffineResources
|
||||
import SwiftUI
|
||||
|
||||
struct AffinePaywallPageView: View {
|
||||
@StateObject var viewModel = ViewModel()
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State private var presentAnimation = false
|
||||
@State private var showCloseButton = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
CategorySelectionView(
|
||||
selectedTab: viewModel.category,
|
||||
onSelect: viewModel.select(category:)
|
||||
)
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.dismiss()
|
||||
} label: {
|
||||
AffineIcons.close.image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(AffineColors.textSecondary.color)
|
||||
.opacity(showCloseButton ? 1 : 0)
|
||||
.disabled(!showCloseButton)
|
||||
.animation(.spring, value: showCloseButton)
|
||||
}
|
||||
ZStack(alignment: .topLeading) {
|
||||
Spacer()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
content
|
||||
.frame(maxWidth: .infinity)
|
||||
.transition(
|
||||
.opacity
|
||||
.combined(with: .scale(
|
||||
scale: 0.95,
|
||||
anchor: .init(x: 0.5, y: 0)
|
||||
))
|
||||
)
|
||||
}
|
||||
.animation(.spring.speed(2), value: viewModel.category)
|
||||
|
||||
PurchaseFooterView(viewModel: viewModel)
|
||||
.animation(.spring.speed(2), value: viewModel.selectedPackageIdentifier)
|
||||
}
|
||||
.padding()
|
||||
.opacity(presentAnimation ? 1 : 0)
|
||||
.scaleEffect(presentAnimation ? 1 : 0.95, anchor: .top)
|
||||
.animation(.spring, value: presentAnimation)
|
||||
.onAppear {
|
||||
presentAnimation = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
showCloseButton = true
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
AffineColors.layerBackgroundSecondary.color
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var content: some View {
|
||||
switch viewModel.category {
|
||||
case .pro:
|
||||
SKUnitProDetailView(viewModel: viewModel)
|
||||
case .ai:
|
||||
SKUnitIntelligentDetailView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
struct PreviewWrapper: View {
|
||||
@StateObject var viewModel = ViewModel()
|
||||
var body: some View {
|
||||
AffinePaywallPageView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
return PreviewWrapper()
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//
|
||||
// Paywall.swift
|
||||
// AffinePaywall
|
||||
//
|
||||
// Created by qaq on 9/18/25.
|
||||
//
|
||||
|
||||
import RevenueCat
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
public enum Paywall {
|
||||
package static let revenueCatToken: String = "appl_FIzFhieVpSSmJRYJWwhVrgtnsVf"
|
||||
package static let revenueCatProxyEndpoit = URL(string: "https://iap.affine.pro/")!
|
||||
package static var isPurchasesConfigured = false
|
||||
|
||||
private static let setupExecution: Void = {
|
||||
#if DEBUG
|
||||
Purchases.logLevel = .debug
|
||||
#endif
|
||||
Purchases.proxyURL = revenueCatProxyEndpoit
|
||||
return ()
|
||||
}()
|
||||
|
||||
nonisolated
|
||||
public static func setup() {
|
||||
_ = setupExecution
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public static func presentWall(
|
||||
toController controller: UIViewController,
|
||||
bindWebContext context: WKWebView?,
|
||||
type: String
|
||||
) {
|
||||
let viewModel = ViewModel()
|
||||
if let context { viewModel.bind(context: context) }
|
||||
switch type.lowercased() {
|
||||
case "pro":
|
||||
viewModel.select(category: .pro)
|
||||
viewModel.select(subcategory: SKUnitSubcategoryProPlan.default)
|
||||
case "ai":
|
||||
viewModel.select(category: .ai)
|
||||
viewModel.select(subcategory: SKUnitSingleSubcategory.single)
|
||||
default:
|
||||
break
|
||||
}
|
||||
let view = AffinePaywallPageView(viewModel: viewModel)
|
||||
let hostingController = UIHostingController(rootView: view)
|
||||
viewModel.bind(controller: hostingController)
|
||||
hostingController.modalPresentationStyle = .overFullScreen
|
||||
hostingController.modalTransitionStyle = .coverVertical
|
||||
hostingController.preferredContentSize = CGSize(width: 555, height: 555) // for iPads
|
||||
controller.present(hostingController, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 3.25C4.48122 3.25 3.25 4.48122 3.25 6V18C3.25 19.5188 4.48122 20.75 6 20.75H18C19.5188 20.75 20.75 19.5188 20.75 18V6C20.75 4.48122 19.5188 3.25 18 3.25H6ZM4.75 6C4.75 5.30964 5.30964 4.75 6 4.75H18C18.6904 4.75 19.25 5.30964 19.25 6V18C19.25 18.6904 18.6904 19.25 18 19.25H6C5.30964 19.25 4.75 18.6904 4.75 18V6ZM16.5303 9.53033C16.8232 9.23744 16.8232 8.76256 16.5303 8.46967C16.2374 8.17678 15.7626 8.17678 15.4697 8.46967L10.5 13.4393L9.03033 11.9697C8.73744 11.6768 8.26256 11.6768 7.96967 11.9697C7.67678 12.2626 7.67678 12.7374 7.96967 13.0303L9.96967 15.0303C10.2626 15.3232 10.7374 15.3232 11.0303 15.0303L16.5303 9.53033Z" fill="#1E96EB"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 806 B |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_CHECK.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7786 4.72105C19.1493 4.09298 18.1284 4.09298 17.4991 4.72105L16.6144 5.60408L18.8918 7.87702L19.7786 6.9919C20.4071 6.36465 20.4071 5.3483 19.7786 4.72105ZM17.8301 8.93664L15.5526 6.6637L4.75 17.4451V19.7501H6.99534L17.8301 8.93664ZM16.4395 3.65934C17.6544 2.44689 19.6234 2.44689 20.8383 3.65934C22.0539 4.87262 22.0539 6.84033 20.8383 8.05361L7.83537 21.0309C7.69476 21.1712 7.50422 21.2501 7.30557 21.2501H4C3.58579 21.2501 3.25 20.9143 3.25 20.5001V17.134C3.25 16.9348 3.32922 16.7438 3.47019 16.6032L16.4395 3.65934Z" fill="#1E96EB"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 697 B |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_PEN.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 172 KiB |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_PREVIEW_B.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AI_PREVIEW_B 1.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 135 KiB |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_PREVIEW_C.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AI_PREVIEW_C 1.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 132 KiB |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_PREVIEW_A 1.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AI_PREVIEW_A.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.75 4C3.75 3.58579 4.08579 3.25 4.5 3.25H20.5C20.9142 3.25 21.25 3.58579 21.25 4V6.66667C21.25 7.08088 20.9142 7.41667 20.5 7.41667C20.0858 7.41667 19.75 7.08088 19.75 6.66667V4.75H13.25V19.25H16.5C16.9142 19.25 17.25 19.5858 17.25 20C17.25 20.4142 16.9142 20.75 16.5 20.75H8.5C8.08579 20.75 7.75 20.4142 7.75 20C7.75 19.5858 8.08579 19.25 8.5 19.25H11.75V4.75H5.25V6.66667C5.25 7.08088 4.91421 7.41667 4.5 7.41667C4.08579 7.41667 3.75 7.08088 3.75 6.66667V4Z" fill="#1E96EB"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 633 B |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AI_TEXT.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 238 KiB |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Image.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "BELIVER_ICON.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||