diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index fbeef41e00..1df5d4de5e 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 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 = ""; }; + 50B000032F01000000000000 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; + 50B000042F01000000000000 /* OnboardingRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingRootView.swift; sourceTree = ""; }; + 50B000092F01000000000000 /* AppPaywallViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPaywallViewController.swift; sourceTree = ""; }; + 50B0000F2F01000000000000 /* OnboardingIntroViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingIntroViews.swift; sourceTree = ""; }; + 50B000112F01000000000000 /* OnboardingFeatureViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFeatureViews.swift; sourceTree = ""; }; + 50B000132F01000000000000 /* AppPaywallStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPaywallStyle.swift; sourceTree = ""; }; + 50B000152F01000000000000 /* AppPaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPaywallView.swift; sourceTree = ""; }; + 50B000172F01000000000000 /* AppPaywallCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPaywallCards.swift; sourceTree = ""; }; 50BBC54E2E840DBC0067C5E2 /* Products.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Products.storekit; sourceTree = ""; }; + 50C000022F02000000000000 /* NativeSignInViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInViewController.swift; sourceTree = ""; }; + 50C0000B2F02000000000000 /* NativeSignInViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInViewModel.swift; sourceTree = ""; }; + 50C0000D2F02000000000000 /* NativeSignInStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInStyle.swift; sourceTree = ""; }; + 50C0000F2F02000000000000 /* NativeSignInWebBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInWebBridge.swift; sourceTree = ""; }; + 50C000112F02000000000000 /* NativeSignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInView.swift; sourceTree = ""; }; + 50C000132F02000000000000 /* NativeSignInHUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInHUD.swift; sourceTree = ""; }; + 50C000152F02000000000000 /* NativeSignInComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInComponents.swift; sourceTree = ""; }; + 50D000022F03000000000000 /* ColdStartSignInSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColdStartSignInSheetViewController.swift; sourceTree = ""; }; 50CECF1E2E7C1084004487AA /* AffineResources */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineResources; sourceTree = ""; }; 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBridgedWindowScript.swift; sourceTree = ""; }; 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AffineViewController+AIButton.swift"; sourceTree = ""; }; @@ -171,6 +205,36 @@ path = Packages; sourceTree = ""; }; + 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 = ""; + }; + 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 = ""; + }; 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 */ diff --git a/packages/frontend/apps/ios/App/App/AffineViewController+AIButton.swift b/packages/frontend/apps/ios/App/App/AffineViewController+AIButton.swift index 49d4fbb35b..4667681588 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController+AIButton.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController+AIButton.swift @@ -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) + } } diff --git a/packages/frontend/apps/ios/App/App/AffineViewController.swift b/packages/frontend/apps/ios/App/App/AffineViewController.swift index 9a2d30492c..a9d8e79547 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController.swift @@ -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() + } } } } diff --git a/packages/frontend/apps/ios/App/App/ApplicationBridgedWindowScript.swift b/packages/frontend/apps/ios/App/App/ApplicationBridgedWindowScript.swift index 5140e50e0c..dc795c08c4 100644 --- a/packages/frontend/apps/ios/App/App/ApplicationBridgedWindowScript.swift +++ b/packages/frontend/apps/ios/App/App/ApplicationBridgedWindowScript.swift @@ -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 } } diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/NativeLoginBackground.imageset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/NativeLoginBackground.imageset/Contents.json new file mode 100644 index 0000000000..969e0b2304 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/NativeLoginBackground.imageset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/NativeLoginBackground.imageset/native-login-background.png b/packages/frontend/apps/ios/App/App/Assets.xcassets/NativeLoginBackground.imageset/native-login-background.png new file mode 100644 index 0000000000..511a5501a4 Binary files /dev/null and b/packages/frontend/apps/ios/App/App/Assets.xcassets/NativeLoginBackground.imageset/native-login-background.png differ diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/NativeLoginLogo.imageset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/NativeLoginLogo.imageset/Contents.json new file mode 100644 index 0000000000..993fad921b --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/NativeLoginLogo.imageset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/NativeLoginLogo.imageset/native-login-logo.png b/packages/frontend/apps/ios/App/App/Assets.xcassets/NativeLoginLogo.imageset/native-login-logo.png new file mode 100644 index 0000000000..ccd937efe2 Binary files /dev/null and b/packages/frontend/apps/ios/App/App/Assets.xcassets/NativeLoginLogo.imageset/native-login-logo.png differ diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingBackground.colorset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingBackground.colorset/Contents.json new file mode 100644 index 0000000000..6bcd9205ac --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingBackground.colorset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureAI.imageset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureAI.imageset/Contents.json new file mode 100644 index 0000000000..a46dbde7e6 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureAI.imageset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureAI.imageset/onboarding-feature-ai.png b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureAI.imageset/onboarding-feature-ai.png new file mode 100644 index 0000000000..eb33fa8bfa Binary files /dev/null and b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureAI.imageset/onboarding-feature-ai.png differ diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureBiggerPicture.imageset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureBiggerPicture.imageset/Contents.json new file mode 100644 index 0000000000..10dbea710a --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureBiggerPicture.imageset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureBiggerPicture.imageset/onboarding-feature-bigger-picture.png b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureBiggerPicture.imageset/onboarding-feature-bigger-picture.png new file mode 100644 index 0000000000..8d88fe5ba6 Binary files /dev/null and b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureBiggerPicture.imageset/onboarding-feature-bigger-picture.png differ diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureClearDocs.imageset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureClearDocs.imageset/Contents.json new file mode 100644 index 0000000000..9d7a6e371e --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureClearDocs.imageset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureClearDocs.imageset/onboarding-feature-clear-docs.png b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureClearDocs.imageset/onboarding-feature-clear-docs.png new file mode 100644 index 0000000000..8d50a7bb54 Binary files /dev/null and b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureClearDocs.imageset/onboarding-feature-clear-docs.png differ diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureEveryDevice.imageset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureEveryDevice.imageset/Contents.json new file mode 100644 index 0000000000..e7a91555f7 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureEveryDevice.imageset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureEveryDevice.imageset/onboarding-feature-every-device.png b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureEveryDevice.imageset/onboarding-feature-every-device.png new file mode 100644 index 0000000000..9f6492bb89 Binary files /dev/null and b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureEveryDevice.imageset/onboarding-feature-every-device.png differ diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureMultipleViews.imageset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureMultipleViews.imageset/Contents.json new file mode 100644 index 0000000000..c9e4de3fc4 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureMultipleViews.imageset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureMultipleViews.imageset/onboarding-feature-multiple-views.png b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureMultipleViews.imageset/onboarding-feature-multiple-views.png new file mode 100644 index 0000000000..376ec52ef8 Binary files /dev/null and b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingFeatureMultipleViews.imageset/onboarding-feature-multiple-views.png differ diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingIntroBackground.colorset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingIntroBackground.colorset/Contents.json new file mode 100644 index 0000000000..822d5a5874 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingIntroBackground.colorset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingIntroReference.imageset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingIntroReference.imageset/Contents.json new file mode 100644 index 0000000000..af5697b12d --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingIntroReference.imageset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingIntroReference.imageset/onboarding-intro-reference.png b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingIntroReference.imageset/onboarding-intro-reference.png new file mode 100644 index 0000000000..0409756989 Binary files /dev/null and b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingIntroReference.imageset/onboarding-intro-reference.png differ diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleEducator.imageset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleEducator.imageset/Contents.json new file mode 100644 index 0000000000..6cab5a9959 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleEducator.imageset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleEducator.imageset/onboarding-role-educator.png b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleEducator.imageset/onboarding-role-educator.png new file mode 100644 index 0000000000..c91a770ca0 Binary files /dev/null and b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleEducator.imageset/onboarding-role-educator.png differ diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleOther.imageset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleOther.imageset/Contents.json new file mode 100644 index 0000000000..8361ca7ecd --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleOther.imageset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleOther.imageset/onboarding-role-other.png b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleOther.imageset/onboarding-role-other.png new file mode 100644 index 0000000000..f23172de58 Binary files /dev/null and b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleOther.imageset/onboarding-role-other.png differ diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleProfessional.imageset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleProfessional.imageset/Contents.json new file mode 100644 index 0000000000..6a9c105bbf --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleProfessional.imageset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleProfessional.imageset/onboarding-role-professional.png b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleProfessional.imageset/onboarding-role-professional.png new file mode 100644 index 0000000000..942ee60729 Binary files /dev/null and b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleProfessional.imageset/onboarding-role-professional.png differ diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleStudent.imageset/Contents.json b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleStudent.imageset/Contents.json new file mode 100644 index 0000000000..2237006a7a --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleStudent.imageset/Contents.json @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleStudent.imageset/onboarding-role-student.png b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleStudent.imageset/onboarding-role-student.png new file mode 100644 index 0000000000..11e9f06548 Binary files /dev/null and b/packages/frontend/apps/ios/App/App/Assets.xcassets/OnboardingRoleStudent.imageset/onboarding-role-student.png differ diff --git a/packages/frontend/apps/ios/App/App/ColdStartSignInSheetViewController.swift b/packages/frontend/apps/ios/App/App/ColdStartSignInSheetViewController.swift new file mode 100644 index 0000000000..fad644ed9b --- /dev/null +++ b/packages/frontend/apps/ios/App/App/ColdStartSignInSheetViewController.swift @@ -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) + } +} diff --git a/packages/frontend/apps/ios/App/App/Localizable.xcstrings b/packages/frontend/apps/ios/App/App/Localizable.xcstrings index fa56d16199..134b3be57a 100644 --- a/packages/frontend/apps/ios/App/App/Localizable.xcstrings +++ b/packages/frontend/apps/ios/App/App/Localizable.xcstrings @@ -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" -} \ No newline at end of file +} diff --git a/packages/frontend/apps/ios/App/App/Onboarding/AppPaywallCards.swift b/packages/frontend/apps/ios/App/App/Onboarding/AppPaywallCards.swift new file mode 100644 index 0000000000..96767f5589 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Onboarding/AppPaywallCards.swift @@ -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: {} + ) +} diff --git a/packages/frontend/apps/ios/App/App/Onboarding/AppPaywallStyle.swift b/packages/frontend/apps/ios/App/App/Onboarding/AppPaywallStyle.swift new file mode 100644 index 0000000000..5b4abdd301 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Onboarding/AppPaywallStyle.swift @@ -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) + } +} diff --git a/packages/frontend/apps/ios/App/App/Onboarding/AppPaywallView.swift b/packages/frontend/apps/ios/App/App/Onboarding/AppPaywallView.swift new file mode 100644 index 0000000000..1664764f33 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Onboarding/AppPaywallView.swift @@ -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.. 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() + } +} diff --git a/packages/frontend/apps/ios/App/App/Onboarding/AppPaywallViewController.swift b/packages/frontend/apps/ios/App/App/Onboarding/AppPaywallViewController.swift new file mode 100644 index 0000000000..cb75d1f0eb --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Onboarding/AppPaywallViewController.swift @@ -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? + private var prepareTask: Task? + private var actionTask: Task? + + 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", + ] + } + } +} diff --git a/packages/frontend/apps/ios/App/App/Onboarding/OnboardingFeatureViews.swift b/packages/frontend/apps/ios/App/App/Onboarding/OnboardingFeatureViews.swift new file mode 100644 index 0000000000..aeea0e1353 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Onboarding/OnboardingFeatureViews.swift @@ -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.. 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() + } +} diff --git a/packages/frontend/apps/ios/App/App/Onboarding/OnboardingRootView.swift b/packages/frontend/apps/ios/App/App/Onboarding/OnboardingRootView.swift new file mode 100644 index 0000000000..ff655d35c8 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Onboarding/OnboardingRootView.swift @@ -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" + } + } +} diff --git a/packages/frontend/apps/ios/App/App/Onboarding/OnboardingViewController.swift b/packages/frontend/apps/ios/App/App/Onboarding/OnboardingViewController.swift new file mode 100644 index 0000000000..357fcf9090 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Onboarding/OnboardingViewController.swift @@ -0,0 +1,30 @@ +import SwiftUI +import UIKit + +final class OnboardingViewController: UIViewController { + var onCompleteOnboarding: (() -> Void)? + + private var hostingController: UIHostingController? + + 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) + } +} diff --git a/packages/frontend/apps/ios/App/App/Plugins/AffineTheme/AffineThemePlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/AffineTheme/AffineThemePlugin.swift index 39c7d9bf57..d74976e5a8 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/AffineTheme/AffineThemePlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/AffineTheme/AffineThemePlugin.swift @@ -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() } diff --git a/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift index 307eec38da..637a44676e 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift @@ -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) } diff --git a/packages/frontend/apps/ios/App/App/Plugins/PayWall/PayWallPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/PayWall/PayWallPlugin.swift index 5497052b0e..0dedfe2c64 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/PayWall/PayWallPlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/PayWall/PayWallPlugin.swift @@ -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 } } } diff --git a/packages/frontend/apps/ios/App/App/RootViewController.swift b/packages/frontend/apps/ios/App/App/RootViewController.swift index bc0df08b86..2798958d54 100644 --- a/packages/frontend/apps/ios/App/App/RootViewController.swift +++ b/packages/frontend/apps/ios/App/App/RootViewController.swift @@ -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) + } } diff --git a/packages/frontend/apps/ios/App/App/SignIn/NativeSignInComponents.swift b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInComponents.swift new file mode 100644 index 0000000000..5526651147 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInComponents.swift @@ -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) + } +} diff --git a/packages/frontend/apps/ios/App/App/SignIn/NativeSignInHUD.swift b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInHUD.swift new file mode 100644 index 0000000000..df50e163f0 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInHUD.swift @@ -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 + } + } + } +} + diff --git a/packages/frontend/apps/ios/App/App/SignIn/NativeSignInStyle.swift b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInStyle.swift new file mode 100644 index 0000000000..ba386369ad --- /dev/null +++ b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInStyle.swift @@ -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 + } +} + diff --git a/packages/frontend/apps/ios/App/App/SignIn/NativeSignInView.swift b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInView.swift new file mode 100644 index 0000000000..6611219f42 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInView.swift @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/SignIn/NativeSignInViewController.swift b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInViewController.swift new file mode 100644 index 0000000000..48530c6197 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInViewController.swift @@ -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) + } + } +} diff --git a/packages/frontend/apps/ios/App/App/SignIn/NativeSignInViewModel.swift b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInViewModel.swift new file mode 100644 index 0000000000..cb513bdaae --- /dev/null +++ b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInViewModel.swift @@ -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? + private var appearanceTask: Task? + 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 + } +} diff --git a/packages/frontend/apps/ios/App/App/SignIn/NativeSignInWebBridge.swift b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInWebBridge.swift new file mode 100644 index 0000000000..b1e75b9d3d --- /dev/null +++ b/packages/frontend/apps/ios/App/App/SignIn/NativeSignInWebBridge.swift @@ -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: "")] + ) + } +} diff --git a/packages/frontend/apps/ios/App/App/Tools.swift b/packages/frontend/apps/ios/App/App/Tools.swift index f5f8bb4f20..0438b553eb 100644 --- a/packages/frontend/apps/ios/App/App/Tools.swift +++ b/packages/frontend/apps/ios/App/App/Tools.swift @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/AuthAccessTokenCache.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/AuthAccessTokenCache.swift new file mode 100644 index 0000000000..72d1c291bc --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/AuthAccessTokenCache.swift @@ -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)" + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift index 95564c3136..d3d5f8fa95 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift @@ -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"), ] ), diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/IntelligentFeatureView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/IntelligentFeatureView.swift deleted file mode 100644 index bd64326db1..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/IntelligentFeatureView.swift +++ /dev/null @@ -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() -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/OffsetObservingScrollView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/OffsetObservingScrollView.swift deleted file mode 100644 index fecc359724..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/OffsetObservingScrollView.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// OffsetObservingScrollView.swift -// AffinePaywall -// -// Created by qaq on 9/23/25. -// - -import SwiftUI - -struct OffsetObservingScrollView: 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) - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/PositionObservingView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/PositionObservingView.swift deleted file mode 100644 index dd1ebed921..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/PositionObservingView.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// PositionObservingView.swift -// AffinePaywall -// -// Created by qaq on 9/23/25. -// - -import SwiftUI - -struct PositionObservingView: 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 - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView+Feature.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView+Feature.swift deleted file mode 100644 index a5d65be00b..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView+Feature.swift +++ /dev/null @@ -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", - ] - ), - ] -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView.swift deleted file mode 100644 index 82e0aa9762..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView.swift +++ /dev/null @@ -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() -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/CategorySelectionView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/CategorySelectionView.swift deleted file mode 100644 index 7610284252..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/CategorySelectionView.swift +++ /dev/null @@ -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()) -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/HeadlineView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/HeadlineView.swift deleted file mode 100644 index fbf9e6f137..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/HeadlineView.swift +++ /dev/null @@ -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) - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PackageOptionView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PackageOptionView.swift deleted file mode 100644 index 3b3a773fd1..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PackageOptionView.swift +++ /dev/null @@ -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()) -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PageDotsView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PageDotsView.swift deleted file mode 100644 index 375afb22c9..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PageDotsView.swift +++ /dev/null @@ -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() -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PurchaseFooterView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PurchaseFooterView.swift deleted file mode 100644 index f9839aaefd..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PurchaseFooterView.swift +++ /dev/null @@ -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()) -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/TheGiveMeMoneyButtonView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/TheGiveMeMoneyButtonView.swift deleted file mode 100644 index b56fa459a2..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/TheGiveMeMoneyButtonView.swift +++ /dev/null @@ -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) -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/ProFeatureRowView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/ProFeatureRowView.swift deleted file mode 100644 index 8d09ad2d42..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/ProFeatureRowView.swift +++ /dev/null @@ -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()) -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/SKUnitProDetailView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/SKUnitProDetailView.swift deleted file mode 100644 index 5b976fc57c..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/SKUnitProDetailView.swift +++ /dev/null @@ -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()) -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Feature.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Feature.swift deleted file mode 100644 index 307eb0e2d7..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Feature.swift +++ /dev/null @@ -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 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+AI.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+AI.swift deleted file mode 100644 index b9e780193d..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+AI.swift +++ /dev/null @@ -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" - ), - ] - ), - ] -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Pro.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Pro.swift deleted file mode 100644 index 6ea8813550..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Pro.swift +++ /dev/null @@ -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 - ), - ] - ), - ] -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit.swift deleted file mode 100644 index 9020c7b3cf..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit.swift +++ /dev/null @@ -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 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitCategory.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitCategory.swift deleted file mode 100644 index ba5c692a69..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitCategory.swift +++ /dev/null @@ -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" - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitPackageOption.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitPackageOption.swift deleted file mode 100644 index b3dbfc272d..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitPackageOption.swift +++ /dev/null @@ -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 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategorizable.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategorizable.swift deleted file mode 100644 index a397bd2ea4..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategorizable.swift +++ /dev/null @@ -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 -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategoryProPlan.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategoryProPlan.swift deleted file mode 100644 index 082ae14d9c..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategoryProPlan.swift +++ /dev/null @@ -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"), - ] - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Store.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Store.swift deleted file mode 100644 index 60b404ffc5..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Store.swift +++ /dev/null @@ -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 { - var purchasedItems: Set = [] - - 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 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift deleted file mode 100644 index 928cf41495..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift +++ /dev/null @@ -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 { - var ans: Set = [] - - 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 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Preview.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Preview.swift deleted file mode 100644 index 79286fefa6..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Preview.swift +++ /dev/null @@ -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 - }() -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel.swift deleted file mode 100644 index 8491718e16..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel.swift +++ /dev/null @@ -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 = [] - @Published var externalPurchasedItems: Set = [] - @Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package) - - var purchasedItems: Set { - Set() - .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) } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/NativePaywallBridge.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/NativePaywallBridge.swift new file mode 100644 index 0000000000..f57e70cdd4 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/NativePaywallBridge.swift @@ -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: "")] + ) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Page/AffinePaywallPageView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Page/AffinePaywallPageView.swift deleted file mode 100644 index f9a57431f6..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Page/AffinePaywallPageView.swift +++ /dev/null @@ -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() -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift deleted file mode 100644 index d908b3017b..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift +++ /dev/null @@ -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) - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_CHECK.imageset/AI_CHECK.svg b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_CHECK.imageset/AI_CHECK.svg deleted file mode 100644 index 9bb992a3b9..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_CHECK.imageset/AI_CHECK.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_CHECK.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_CHECK.imageset/Contents.json deleted file mode 100644 index 58f352b1d1..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_CHECK.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "AI_CHECK.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PEN.imageset/AI_PEN.svg b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PEN.imageset/AI_PEN.svg deleted file mode 100644 index 1acf9773fe..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PEN.imageset/AI_PEN.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PEN.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PEN.imageset/Contents.json deleted file mode 100644 index b109fe5788..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PEN.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "AI_PEN.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/AI_PREVIEW_B 1.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/AI_PREVIEW_B 1.png deleted file mode 100644 index b99cc69f69..0000000000 Binary files a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/AI_PREVIEW_B 1.png and /dev/null differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/AI_PREVIEW_B.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/AI_PREVIEW_B.png deleted file mode 100644 index 8b62264868..0000000000 Binary files a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/AI_PREVIEW_B.png and /dev/null differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/Contents.json deleted file mode 100644 index 874a031f02..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/Contents.json +++ /dev/null @@ -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 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/AI_PREVIEW_C 1.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/AI_PREVIEW_C 1.png deleted file mode 100644 index ff3f081320..0000000000 Binary files a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/AI_PREVIEW_C 1.png and /dev/null differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/AI_PREVIEW_C.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/AI_PREVIEW_C.png deleted file mode 100644 index 32fb5680a5..0000000000 Binary files a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/AI_PREVIEW_C.png and /dev/null differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/Contents.json deleted file mode 100644 index 5680eac40b..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/Contents.json +++ /dev/null @@ -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 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/AI_PREVIEW_A 1.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/AI_PREVIEW_A 1.png deleted file mode 100644 index a8bf3fe91f..0000000000 Binary files a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/AI_PREVIEW_A 1.png and /dev/null differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/AI_PREVIEW_A.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/AI_PREVIEW_A.png deleted file mode 100644 index 5cd2b02639..0000000000 Binary files a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/AI_PREVIEW_A.png and /dev/null differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/Contents.json deleted file mode 100644 index dd7c7136a2..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/Contents.json +++ /dev/null @@ -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 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_TEXT.imageset/AI_TEXT.svg b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_TEXT.imageset/AI_TEXT.svg deleted file mode 100644 index dbb3838848..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_TEXT.imageset/AI_TEXT.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_TEXT.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_TEXT.imageset/Contents.json deleted file mode 100644 index 01771f7190..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_TEXT.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "AI_TEXT.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/BELIVER_ICON.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/BELIVER_ICON.png deleted file mode 100644 index 700f5483a6..0000000000 Binary files a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/BELIVER_ICON.png and /dev/null differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/Contents.json deleted file mode 100644 index 2a87649bcd..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/Contents.json +++ /dev/null @@ -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 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/Image.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/Image.png deleted file mode 100644 index 2a516ddc14..0000000000 Binary files a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/Image.png and /dev/null differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/Contents.json deleted file mode 100644 index 73c00596a7..0000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift index 486f05bd13..8f70d31dbd 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift @@ -177,6 +177,7 @@ private extension ChatManager { timeoutInterval: 10 ) request.setValue("close", forHTTPHeaderField: "Connection") + request = QLService.shared.authorized(request) let closable = ClosableTask(detachedTask: .detached(operation: { let eventSource = EventSource() diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext.swift index 1c9034c762..f9996cdc57 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext.swift @@ -70,7 +70,10 @@ public class IntelligentContext { private init() {} - public func preparePresent(_ completion: @escaping (Result) -> Void) { + public func preparePresent( + createSession shouldCreateSession: Bool = true, + _ completion: @escaping (Result) -> Void + ) { assert(webView != nil) DispatchQueue.global(qos: .userInitiated).async { [self] in prepareTemporaryDirectory() @@ -130,6 +133,10 @@ public class IntelligentContext { let currentDocumentId: String? = webViewMetadata[.currentDocId] as? String dumpMetadataContents() + guard shouldCreateSession else { + completion(.success(())) + return + } createSession( workspaceId: workspaceId, diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/QLService+URLSessionCookieClient.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/QLService+URLSessionCookieClient.swift index 0c4e32f107..b0631f0afe 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/QLService+URLSessionCookieClient.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/QLService+URLSessionCookieClient.swift @@ -6,6 +6,7 @@ // import Apollo +import AffineGraphQL import Foundation extension QLService { @@ -17,5 +18,36 @@ extension QLService { self.session.configuration.httpCookieStorage?.setCookie(cookie) } } + + @discardableResult + override func sendRequest( + _ request: URLRequest, + taskDescription: String?, + rawTaskCompletionHandler: RawCompletion?, + completion: @escaping Completion + ) -> URLSessionTask { + super.sendRequest( + QLService.shared.authorized(request), + taskDescription: taskDescription, + rawTaskCompletionHandler: rawTaskCompletionHandler, + completion: completion + ) + } + } +} + +extension QLService { + func authorized(_ request: URLRequest) -> URLRequest { + guard request.value(forHTTPHeaderField: "Authorization") == nil, + let requestURL = request.url, + let token = AuthAccessTokenCache.shared.token( + for: requestURL, matching: serverBaseURL) + else { + return request + } + + var authorized = request + authorized.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return authorized } } diff --git a/packages/frontend/apps/ios/App/Podfile b/packages/frontend/apps/ios/App/Podfile index 51d2101722..07fa07a1fd 100644 --- a/packages/frontend/apps/ios/App/Podfile +++ b/packages/frontend/apps/ios/App/Podfile @@ -1,6 +1,6 @@ require_relative '../../../../../node_modules/@capacitor/ios/scripts/pods_helpers' -platform :ios, '15.0' +platform :ios, '16.5' use_frameworks! # workaround to avoid Xcode caching of Pods that requires diff --git a/packages/frontend/apps/ios/App/Podfile.lock b/packages/frontend/apps/ios/App/Podfile.lock index 49d8a70341..7e43f3ce8d 100644 --- a/packages/frontend/apps/ios/App/Podfile.lock +++ b/packages/frontend/apps/ios/App/Podfile.lock @@ -54,6 +54,6 @@ SPEC CHECKSUMS: CapacitorPluginAppTrackingTransparency: f0df8fe65d07d3854de3dad832caf192262bbaa5 CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483 -PODFILE CHECKSUM: ea5ce301a5d89089eaf7d25b0c1fd49293dcfc20 +PODFILE CHECKSUM: c36da240bb7ce656c8648d643cd942599f770b59 COCOAPODS: 1.17.0 diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index 4a1b9f3105..dcab19ea8a 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -8,6 +8,7 @@ import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordina import { HapticProvider } from '@affine/core/mobile/modules/haptics'; import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard'; import { router } from '@affine/core/mobile/router'; +import { getCurrentNativeUserIdentifier } from '@affine/core/mobile/utils/native-user-identifier'; import { configureCommonModules } from '@affine/core/modules'; import { AuthProvider, @@ -20,6 +21,7 @@ import { ValidatorProvider, } from '@affine/core/modules/cloud'; import { registerNativePreviewHandlers } from '@affine/core/modules/code-block-preview-renderer'; +import { GlobalDialogService } from '@affine/core/modules/dialogs'; import { DocsService } from '@affine/core/modules/doc'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { GlobalContextService } from '@affine/core/modules/global-context'; @@ -30,7 +32,7 @@ import { configureLocalStorageStateStorageImpls, NbstoreProvider, } from '@affine/core/modules/storage'; -import { PopupWindowProvider } from '@affine/core/modules/url'; +import { PopupWindowProvider, UrlService } from '@affine/core/modules/url'; import { ClientSchemeProvider } from '@affine/core/modules/url/providers/client-schema'; import { configureBrowserWorkbenchModule, @@ -43,6 +45,7 @@ import { import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine'; import { getWorkerUrl } from '@affine/env/worker'; import { + OAuthProviderType, refreshSubscriptionMutation, requestApplySubscriptionMutation, } from '@affine/graphql'; @@ -98,6 +101,19 @@ window.addEventListener('beforeunload', () => { storeManagerClient.dispose(); }); +const waitForSubscriptionRevalidation = async ( + subscriptionService: SubscriptionService, + fallbackMessage: string +) => { + await subscriptionService.subscription.waitForRevalidation(); + const error = subscriptionService.subscription.error$.value; + if (error) { + throw error instanceof Error + ? error + : new Error(getErrorMessage(error, fallbackMessage)); + } +}; + const future = { v7_startTransition: true, } as const; @@ -147,19 +163,57 @@ framework.impl(VirtualKeyboardProvider, { let disposeRef = { dispose: () => {}, }; + let viewportDispose = () => {}; + let pluginKeyboardHeight = 0; + let pluginKeyboardVisible = false; + + const getViewportKeyboardHeight = () => { + const viewport = window.visualViewport; + if (!viewport) { + return 0; + } + return Math.max( + 0, + window.innerHeight - viewport.height - viewport.offsetTop + ); + }; + + const emitKeyboardState = () => { + const viewportKeyboardHeight = getViewportKeyboardHeight(); + const effectiveKeyboardHeight = Math.max( + pluginKeyboardHeight, + viewportKeyboardHeight + ); + + callback({ + visible: pluginKeyboardVisible || effectiveKeyboardHeight > 0, + height: effectiveKeyboardHeight, + }); + }; + + const viewport = window.visualViewport; + if (viewport) { + const handleViewportChange = () => { + emitKeyboardState(); + }; + viewport.addEventListener('resize', handleViewportChange); + viewport.addEventListener('scroll', handleViewportChange); + viewportDispose = () => { + viewport.removeEventListener('resize', handleViewportChange); + viewport.removeEventListener('scroll', handleViewportChange); + }; + } Promise.all([ Keyboard.addListener('keyboardWillShow', info => { - callback({ - visible: info.keyboardHeight !== 0, - height: info.keyboardHeight, - }); + pluginKeyboardVisible = info.keyboardHeight !== 0; + pluginKeyboardHeight = info.keyboardHeight; + emitKeyboardState(); }), Keyboard.addListener('keyboardWillHide', () => { - callback({ - visible: false, - height: 0, - }); + pluginKeyboardVisible = false; + pluginKeyboardHeight = 0; + emitKeyboardState(); }), ]) .then(handlers => { @@ -171,8 +225,11 @@ framework.impl(VirtualKeyboardProvider, { }) .catch(console.error); + emitKeyboardState(); + return () => { disposeRef.dispose(); + viewportDispose(); }; }, }); @@ -236,6 +293,8 @@ framework.impl(NativePaywallProvider, { }); const frameworkProvider = framework.provider(); +let cancelActiveRequestSignIn: (() => void) | null = null; +let activeNativeSignInPromise: Promise | null = null; registerNativePreviewHandlers({ renderMermaidSvg: request => Preview.renderMermaidSvg(request), @@ -337,7 +396,18 @@ registerNativeImageFilesPicker(async () => { workspaceRef.dispose(); } }; -(window as any).getCurrentUserIdentifier = () => { +(window as any).getCurrentUserIdentifier = async () => { + const { authService } = getCurrentNativeSignInContext(); + return await getCurrentNativeUserIdentifier(authService); +}; +(window as any).cancelRequestSignIn = () => { + if (!cancelActiveRequestSignIn) { + return false; + } + cancelActiveRequestSignIn(); + return true; +}; +const getCurrentNativeSignInContext = () => { const globalContextService = frameworkProvider.get(GlobalContextService); const currentServerId = globalContextService.globalContext.serverId.get(); const serversService = frameworkProvider.get(ServersService); @@ -345,7 +415,119 @@ registerNativeImageFilesPicker(async () => { const currentServer = (currentServerId ? serversService.server$(currentServerId).value : null) ?? defaultServerService.server; - return currentServer.account$.value?.id; + const authService = currentServer.scope.get(AuthService); + return { authService, currentServer }; +}; + +(window as any).nativeStartOAuthSignIn = async ( + provider: 'Google' | 'Apple' +) => { + const { authService } = getCurrentNativeSignInContext(); + const urlService = frameworkProvider.get(UrlService); + const scheme = urlService.getClientScheme(); + const oauthProvider = + provider === 'Apple' ? OAuthProviderType.Apple : OAuthProviderType.Google; + const options = await authService.oauthPreflight( + oauthProvider, + scheme ?? 'web' + ); + return options.url; +}; + +(window as any).nativeCheckEmailSignInMethods = async (email: string) => { + const { authService } = getCurrentNativeSignInContext(); + const { methods } = await authService.checkUserByEmail(email); + return { + hasPassword: !!methods.password.available, + canUseMagicLink: !!methods.magicLink.available, + }; +}; + +(window as any).nativeSendEmailMagicLink = async (email: string) => { + const { authService } = getCurrentNativeSignInContext(); + await authService.sendEmailMagicLink(email); + return true; +}; + +(window as any).nativeSignInWithMagicLink = async ( + email: string, + token: string +) => { + const { authService } = getCurrentNativeSignInContext(); + await authService.signInMagicLink(email, token, false); + const session = await authService.session.waitForAuthenticated(); + return session.session.account.id; +}; + +(window as any).nativeSignInWithPassword = async ( + email: string, + password: string +) => { + const { authService } = getCurrentNativeSignInContext(); + await authService.signInPassword({ email, password }); + const session = await authService.session.waitForAuthenticated(); + return session.session.account.id; +}; + +(window as any).nativeOpenSelfHostedSignIn = async () => { + const globalDialogService = frameworkProvider.get(GlobalDialogService); + globalDialogService.open('sign-in', { step: 'addSelfhosted' }); + return true; +}; + +const showNativeSignIn = async () => { + const { authService } = getCurrentNativeSignInContext(); + const account = authService.session.account$.value; + if (account?.id) { + return account.id; + } + if (activeNativeSignInPromise) { + const result = await activeNativeSignInPromise; + const authenticatedAccount = authService.session.account$.value; + if (authenticatedAccount?.id) { + return authenticatedAccount.id; + } + return result; + } + + let cancelRequestSignIn!: () => void; + const cancelledSignIn = new Promise<{ success: false }>(resolve => { + cancelRequestSignIn = () => resolve({ success: false }); + }); + cancelActiveRequestSignIn = cancelRequestSignIn; + + activeNativeSignInPromise = (async () => { + const result = await Promise.race([ + Auth.showNativeSignIn(), + cancelledSignIn, + ]); + if (!result.success) { + return null; + } + + const authenticatedAccount = authService.session.account$.value; + if (authenticatedAccount?.id) { + return authenticatedAccount.id; + } + + const session = await authService.session.waitForAuthenticated(); + return session.session.account.id; + })(); + + try { + return await activeNativeSignInPromise; + } finally { + if (cancelActiveRequestSignIn === cancelRequestSignIn) { + cancelActiveRequestSignIn = null; + } + activeNativeSignInPromise = null; + } +}; + +(window as any).showNativeSignIn = showNativeSignIn; + +(window as any).requestSignIn = async () => { + return await showNativeSignIn(); }; (window as any).getCurrentDocContentInMarkdown = async () => { const globalContextService = frameworkProvider.get(GlobalContextService); @@ -451,7 +633,10 @@ registerNativeImageFilesPicker(async () => { (currentServerId ? serversService.server$(currentServerId).value : null) ?? defaultServerService.server; const subscriptionService = currentServer.scope.get(SubscriptionService); - await subscriptionService.subscription.waitForRevalidation(); + await waitForSubscriptionRevalidation( + subscriptionService, + 'Unable to refresh subscription state.' + ); return { pro: subscriptionService.subscription.pro$.value, ai: subscriptionService.subscription.ai$.value, @@ -465,13 +650,14 @@ registerNativeImageFilesPicker(async () => { const currentServer = (currentServerId ? serversService.server$(currentServerId).value : null) ?? defaultServerService.server; - await currentServer - .gql({ - query: refreshSubscriptionMutation, - }) - .catch(console.error); + await currentServer.gql({ + query: refreshSubscriptionMutation, + }); const subscriptionService = currentServer.scope.get(SubscriptionService); - subscriptionService.subscription.revalidate(); + await waitForSubscriptionRevalidation( + subscriptionService, + 'Unable to refresh subscription state.' + ); }; (window as any).requestApplySubscription = async (transactionId: string) => { const globalContextService = frameworkProvider.get(GlobalContextService); @@ -481,14 +667,15 @@ registerNativeImageFilesPicker(async () => { const currentServer = (currentServerId ? serversService.server$(currentServerId).value : null) ?? defaultServerService.server; - await currentServer - .gql({ - query: requestApplySubscriptionMutation, - variables: { transactionId }, - }) - .catch(console.error); + await currentServer.gql({ + query: requestApplySubscriptionMutation, + variables: { transactionId }, + }); const subscriptionService = currentServer.scope.get(SubscriptionService); - subscriptionService.subscription.revalidate(); + await waitForSubscriptionRevalidation( + subscriptionService, + 'Unable to refresh subscription state after purchase.' + ); }; // setup application lifecycle events, and emit application start event @@ -522,62 +709,58 @@ const notifyAuthenticationError = (error: unknown, fallback: string) => { }); }; +const handleAuthenticationCallback = async (url: string) => { + const urlObj = new URL(url); + + if (urlObj.hostname !== 'authentication') { + return; + } + + const method = urlObj.searchParams.get('method'); + const payload = JSON.parse(urlObj.searchParams.get('payload') ?? 'false'); + const serverBaseUrl = urlObj.searchParams.get('server'); + + if (!method || (method !== 'magic-link' && method !== 'oauth') || !payload) { + throw new Error('Invalid authentication url'); + } + + let authService = frameworkProvider + .get(DefaultServerService) + .server.scope.get(AuthService); + + if (serverBaseUrl) { + const serversService = frameworkProvider.get(ServersService); + const server = serversService.getServerByBaseUrl(serverBaseUrl); + if (!server) { + throw new Error( + `Authentication callback server not found: ${serverBaseUrl}` + ); + } + authService = server.scope.get(AuthService); + } + + if (method === 'oauth') { + await authService.signInOauth( + payload.code, + payload.state, + payload.provider + ); + } else if (method === 'magic-link') { + await authService.signInMagicLink(payload.email, payload.token); + } +}; + +(window as any).nativeHandleAuthenticationCallback = async (url: string) => { + await handleAuthenticationCallback(url); + return true; +}; + CapacitorApp.addListener('appUrlOpen', ({ url }) => { // try to close browser if it's open Browser.close().catch(e => console.error('Failed to close browser', e)); - - const urlObj = new URL(url); - - if (urlObj.hostname === 'authentication') { - const method = urlObj.searchParams.get('method'); - const payload = JSON.parse(urlObj.searchParams.get('payload') ?? 'false'); - const serverBaseUrl = urlObj.searchParams.get('server'); - - if ( - !method || - (method !== 'magic-link' && method !== 'oauth') || - !payload - ) { - notifyAuthenticationError( - new Error('Invalid authentication url'), - 'Invalid authentication url' - ); - return; - } - - let authService = frameworkProvider - .get(DefaultServerService) - .server.scope.get(AuthService); - - if (serverBaseUrl) { - const serversService = frameworkProvider.get(ServersService); - const server = serversService.getServerByBaseUrl(serverBaseUrl); - if (!server) { - notifyAuthenticationError( - new Error( - `Authentication callback server not found: ${serverBaseUrl}` - ), - 'Authentication callback server not found' - ); - return; - } - authService = server.scope.get(AuthService); - } - - if (method === 'oauth') { - authService - .signInOauth(payload.code, payload.state, payload.provider) - .catch(error => - notifyAuthenticationError(error, 'Failed to sign in with OAuth') - ); - } else if (method === 'magic-link') { - authService - .signInMagicLink(payload.email, payload.token) - .catch(error => - notifyAuthenticationError(error, 'Failed to sign in with magic link') - ); - } - } + handleAuthenticationCallback(url).catch(error => + notifyAuthenticationError(error, 'Failed to handle authentication callback') + ); }).catch(e => { notifyAuthenticationError(e, 'Failed to handle authentication callback'); }); @@ -587,7 +770,7 @@ AppTrackingTransparency.requestPermission().catch(e => { }); const KeyboardThemeProvider = () => { - const { resolvedTheme } = useTheme(); + const { resolvedTheme, theme } = useTheme(); useEffect(() => { Keyboard.setStyle({ @@ -603,7 +786,18 @@ const KeyboardThemeProvider = () => { }, [resolvedTheme]); useEffect(() => { - const themeMode = resolvedTheme === 'dark' ? 'dark' : 'light'; + if (!theme && !resolvedTheme) { + return; + } + + const themeMode: 'dark' | 'light' | 'system' = + theme === 'dark' || theme === 'light' || theme === 'system' + ? theme + : resolvedTheme === 'dark' + ? 'dark' + : resolvedTheme === 'light' + ? 'light' + : 'system'; (window as any).getCurrentThemeMode = () => { return themeMode; }; @@ -612,7 +806,7 @@ const KeyboardThemeProvider = () => { }).catch(e => { console.error(`Failed to sync app theme: ${e}`); }); - }, [resolvedTheme]); + }, [resolvedTheme, theme]); return null; }; diff --git a/packages/frontend/apps/ios/src/plugins/auth/definitions.ts b/packages/frontend/apps/ios/src/plugins/auth/definitions.ts index 59ac9ee97a..9c83449b9a 100644 --- a/packages/frontend/apps/ios/src/plugins/auth/definitions.ts +++ b/packages/frontend/apps/ios/src/plugins/auth/definitions.ts @@ -1,4 +1,5 @@ export interface AuthPlugin { + showNativeSignIn(): Promise<{ success: boolean }>; signInMagicLink(options: { endpoint: string; email: string; diff --git a/packages/frontend/component/src/ui/modal/prompt-modal.tsx b/packages/frontend/component/src/ui/modal/prompt-modal.tsx index 3f1ac94c9e..239843bd35 100644 --- a/packages/frontend/component/src/ui/modal/prompt-modal.tsx +++ b/packages/frontend/component/src/ui/modal/prompt-modal.tsx @@ -12,12 +12,16 @@ import { import type { ButtonProps } from '../button'; import { Button } from '../button'; import Input, { type InputProps } from '../input'; +import { ModalConfigContext } from './context'; import type { ModalProps } from './modal'; import { Modal } from './modal'; import { desktopStyles, mobileStyles } from './prompt-modal.css'; const styles = BUILD_CONFIG.isMobileEdition ? mobileStyles : desktopStyles; +const getCssLength = (value: string | number) => + typeof value === 'number' ? `${value}px` : value; + export interface PromptModalProps extends ModalProps { confirmButtonOptions?: Omit; onConfirm?: ((text: string) => void) | ((text: string) => Promise); @@ -57,8 +61,10 @@ export const PromptModal = ({ autoFocusConfirm = true, headerClassName, descriptionClassName, + contentOptions, ...props }: PromptModalProps) => { + const { dynamicKeyboardHeight } = useContext(ModalConfigContext); const [value, setValue] = useState(defaultValue ?? ''); const onConfirmClick = useCallback(() => { Promise.resolve(onConfirm?.(value)) @@ -88,8 +94,21 @@ export const PromptModal = ({ { + contentOptions?.onPointerDownOutside?.(e); e.stopPropagation(); onCancel?.(); }, diff --git a/packages/frontend/core/src/components/hooks/affine/use-ai-subscribe.ts b/packages/frontend/core/src/components/hooks/affine/use-ai-subscribe.ts index e8bfc70436..5c9c896336 100644 --- a/packages/frontend/core/src/components/hooks/affine/use-ai-subscribe.ts +++ b/packages/frontend/core/src/components/hooks/affine/use-ai-subscribe.ts @@ -1,5 +1,6 @@ import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify'; import { AuthService, SubscriptionService } from '@affine/core/modules/cloud'; +import { NativePaywallService } from '@affine/core/modules/paywall'; import { UrlService } from '@affine/core/modules/url'; import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql'; import { useFramework } from '@toeverything/infra'; @@ -24,6 +25,14 @@ export const useAISubscribe = () => { return; } + const nativePaywallProvider = framework + .get(NativePaywallService) + .getNativePaywallProvider(); + if (nativePaywallProvider) { + await nativePaywallProvider.showPaywall('AI'); + return; + } + const idempotencyKey = nanoid(); const checkoutOptions = { recurring: SubscriptionRecurring.Yearly, diff --git a/packages/frontend/core/src/mobile/components/navigation/nodes/collection/dialog.tsx b/packages/frontend/core/src/mobile/components/navigation/nodes/collection/dialog.tsx index 8c665facf7..23f19c2356 100644 --- a/packages/frontend/core/src/mobile/components/navigation/nodes/collection/dialog.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/nodes/collection/dialog.tsx @@ -30,6 +30,7 @@ const CollectionDesc = () => { export const CollectionRenameDialog = ({ title, confirmText, + descRenderer, ...props }: RenameDialogProps) => { return ( @@ -37,7 +38,7 @@ export const CollectionRenameDialog = ({ title={title} confirmText={confirmText} {...props} - descRenderer={CollectionDesc} + descRenderer={descRenderer ?? CollectionDesc} /> ); }; diff --git a/packages/frontend/core/src/mobile/components/navigation/sections/collections/index.tsx b/packages/frontend/core/src/mobile/components/navigation/sections/collections/index.tsx index b00bddfb6b..ceeee88458 100644 --- a/packages/frontend/core/src/mobile/components/navigation/sections/collections/index.tsx +++ b/packages/frontend/core/src/mobile/components/navigation/sections/collections/index.tsx @@ -1,4 +1,3 @@ -import { usePromptModal } from '@affine/component'; import { NavigationPanelTreeRoot } from '@affine/core/desktop/components/navigation-panel'; import { CollectionService } from '@affine/core/modules/collection'; import { NavigationPanelService } from '@affine/core/modules/navigation-panel'; @@ -7,11 +6,12 @@ import { useI18n } from '@affine/i18n'; import { track } from '@affine/track'; import { AddCollectionIcon } from '@blocksuite/icons/rc'; import { useLiveData, useServices } from '@toeverything/infra'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; import { CollapsibleSection } from '../../layouts/collapsible-section'; import { NavigationPanelCollectionNode } from '../../nodes/collection'; +import { CollectionRenameDialog } from '../../nodes/collection/dialog'; import * as styles from './index.css'; export const NavigationPanelCollections = () => { @@ -24,42 +24,24 @@ export const NavigationPanelCollections = () => { }); const path = useMemo(() => ['collections'], []); const collectionMetas = useLiveData(collectionService.collectionMetas$); - const { openPromptModal } = usePromptModal(); + const [showNewCollectionDialog, setShowNewCollectionDialog] = useState(false); - const handleCreateCollection = useCallback(() => { - openPromptModal({ - title: t['com.affine.editCollection.saveCollection'](), - label: t['com.affine.editCollectionName.name'](), - inputOptions: { - placeholder: t['com.affine.editCollectionName.name.placeholder'](), - }, - children: ( -
- {t['com.affine.editCollectionName.createTips']()} -
- ), - confirmText: t['com.affine.editCollection.save'](), - cancelText: t['com.affine.editCollection.button.cancel'](), - confirmButtonOptions: { - variant: 'primary', - }, - onConfirm(name) { - const id = collectionService.createCollection({ name }); - track.$.navigationPanel.organize.createOrganizeItem({ - type: 'collection', - }); - workbenchService.workbench.openCollection(id); - navigationPanelService.setCollapsed(path, false); - }, - }); - }, [ - collectionService, - navigationPanelService, - path, - openPromptModal, - t, - workbenchService.workbench, - ]); + const handleCreateCollection = useCallback( + (name: string) => { + const id = collectionService.createCollection({ name }); + track.$.navigationPanel.organize.createOrganizeItem({ + type: 'collection', + }); + workbenchService.workbench.openCollection(id); + navigationPanelService.setCollapsed(path, false); + }, + [ + collectionService, + navigationPanelService, + path, + workbenchService.workbench, + ] + ); return ( { icon={} data-testid="navigation-panel-bar-add-collection-button" label={t['com.affine.rootAppSidebar.collection.new']()} - onClick={() => handleCreateCollection()} + onClick={() => setShowNewCollectionDialog(true)} + /> + ( +
+ {t['com.affine.editCollectionName.createTips']()} +
+ )} />
diff --git a/packages/frontend/core/src/mobile/components/sign-in/index.tsx b/packages/frontend/core/src/mobile/components/sign-in/index.tsx index 4d8761c723..ba6522c6b4 100644 --- a/packages/frontend/core/src/mobile/components/sign-in/index.tsx +++ b/packages/frontend/core/src/mobile/components/sign-in/index.tsx @@ -8,10 +8,12 @@ export const MobileSignInPanel = ({ onClose, server, initStep, + showCloseButton = false, }: { onClose: () => void; server?: string; initStep?: SignInStep; + showCloseButton?: boolean; }) => { const onAuthenticated = useCallback( (status: AuthSessionStatus) => { @@ -23,7 +25,7 @@ export const MobileSignInPanel = ({ ); return ( - + { +export const MobileSignInLayout = ({ + children, + showCloseButton = false, + onClose, +}: PropsWithChildren<{ + showCloseButton?: boolean; + onClose?: () => void; +}>) => { + const shouldShowDismissAffordance = showCloseButton && onClose; + return (
+ {shouldShowDismissAffordance ? ( + } + style={{ borderRadius: 12, padding: 4 }} + onClick={e => { + e.stopPropagation(); + onClose?.(); + }} + /> + ) : null}
{children}
); diff --git a/packages/frontend/core/src/mobile/dialogs/sign-in/index.spec.tsx b/packages/frontend/core/src/mobile/dialogs/sign-in/index.spec.tsx new file mode 100644 index 0000000000..3fe492e9e4 --- /dev/null +++ b/packages/frontend/core/src/mobile/dialogs/sign-in/index.spec.tsx @@ -0,0 +1,109 @@ +/** + * @vitest-environment happy-dom + */ + +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const setNativeSignIn = (implementation: ReturnType) => { + Object.defineProperty(window, 'showNativeSignIn', { + configurable: true, + writable: true, + value: implementation, + }); +}; + +vi.mock('@affine/component', () => ({ + Modal: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, +})); + +vi.mock('@toeverything/theme/v2', () => ({ + cssVarV2: () => 'mock-color', +})); + +vi.mock('../../components/sign-in', () => ({ + MobileSignInPanel: ({ + onClose, + server, + initStep, + showCloseButton, + }: { + onClose: () => void; + server?: string; + initStep?: string; + showCloseButton?: boolean; + }) => ( +
+ mobile-sign-in-panel + {server} + {initStep} + {showCloseButton ? 'show-close' : 'hide-close'} + +
+ ), +})); + +import { SignInDialog } from './index'; + +describe('SignInDialog', () => { + afterEach(() => { + cleanup(); + delete window.showNativeSignIn; + vi.unstubAllGlobals(); + }); + + test('closes the dialog when native sign-in is cancelled', async () => { + const close = vi.fn(); + setNativeSignIn(vi.fn().mockResolvedValue(null)); + + render( + + ); + + await waitFor(() => { + expect(close).toHaveBeenCalledTimes(1); + }); + expect(screen.queryByText('mobile-sign-in-panel')).toBeNull(); + }); + + test('falls back to the web sign-in panel when native bridge is unavailable', async () => { + const close = vi.fn(); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('mobile-sign-in-panel')).not.toBeNull(); + }); + expect(close).not.toHaveBeenCalled(); + }); + + test('closes the dialog after native sign-in succeeds', async () => { + const close = vi.fn(); + setNativeSignIn(vi.fn().mockResolvedValue('user-id')); + + render( + + ); + + await waitFor(() => { + expect(close).toHaveBeenCalledTimes(1); + }); + expect(screen.queryByText('mobile-sign-in-panel')).toBeNull(); + }); +}); diff --git a/packages/frontend/core/src/mobile/dialogs/sign-in/index.tsx b/packages/frontend/core/src/mobile/dialogs/sign-in/index.tsx index e50fd8a86d..a043c55c5f 100644 --- a/packages/frontend/core/src/mobile/dialogs/sign-in/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/sign-in/index.tsx @@ -1,19 +1,78 @@ -import { IconButton, Modal, SafeArea } from '@affine/component'; +import { Modal } from '@affine/component'; import type { SignInStep } from '@affine/core/components/sign-in'; import type { DialogComponentProps, GLOBAL_DIALOG_SCHEMA, } from '@affine/core/modules/dialogs'; -import { CloseIcon } from '@blocksuite/icons/rc'; import { cssVarV2 } from '@toeverything/theme/v2'; +import { useEffect, useRef, useState } from 'react'; import { MobileSignInPanel } from '../../components/sign-in'; +declare global { + interface Window { + showNativeSignIn?: () => Promise; + } +} + export const SignInDialog = ({ close, server: initialServerBaseUrl, step, }: DialogComponentProps) => { + const shouldUseNativeSignIn = step !== 'addSelfhosted'; + const [useWebFallback, setUseWebFallback] = useState(!shouldUseNativeSignIn); + const didRequestNativeSignIn = useRef(false); + const closeRef = useRef(close); + + useEffect(() => { + closeRef.current = close; + }, [close]); + + useEffect(() => { + if ( + !shouldUseNativeSignIn || + useWebFallback || + didRequestNativeSignIn.current + ) { + return; + } + + const showNativeSignIn = window.showNativeSignIn; + if (typeof showNativeSignIn !== 'function') { + setUseWebFallback(true); + return; + } + + didRequestNativeSignIn.current = true; + let isActive = true; + showNativeSignIn() + .then(accountId => { + if (!isActive) { + return; + } + if (accountId) { + closeRef.current(); + return; + } + closeRef.current(); + }) + .catch((error: unknown) => { + console.error('Failed to show native sign-in', error); + if (isActive) { + setUseWebFallback(true); + } + }); + + return () => { + isActive = false; + }; + }, [shouldUseNativeSignIn, useWebFallback]); + + if (!useWebFallback) { + return null; + } + return ( - - } - style={{ borderRadius: 8, padding: 4 }} - onClick={e => { - e.stopPropagation(); - close(); - }} - /> - ); }; diff --git a/packages/frontend/core/src/mobile/pages/workspace/all.tsx b/packages/frontend/core/src/mobile/pages/workspace/all.tsx index a4bda72ff3..1a040a036e 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/all.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/all.tsx @@ -1,16 +1,18 @@ -import { useThemeColorV2, Wrapper } from '@affine/component'; -import { EmptyDocs } from '@affine/core/components/affine/empty'; +import { useThemeColorV2 } from '@affine/component'; +import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils'; import { createDocExplorerContext, DocExplorerContext, } from '@affine/core/components/explorer/context'; import { DocsExplorer } from '@affine/core/components/explorer/docs-view/docs-list'; import { CollectionRulesService } from '@affine/core/modules/collection-rules'; +import { WorkspaceService } from '@affine/core/modules/workspace'; +import { inferOpenMode } from '@affine/core/utils'; import { useLiveData, useService } from '@toeverything/infra'; -import { useEffect, useState } from 'react'; +import { type MouseEvent, useCallback, useEffect, useState } from 'react'; import { Page } from '../../components/page'; -import { AllDocsHeader } from '../../views'; +import { AllDocsHeader, MobileAllDocsEmptyState } from '../../views'; const AllDocs = () => { const [explorerContextValue] = useState(() => @@ -29,11 +31,22 @@ const AllDocs = () => { }) ); const collectionRulesService = useService(CollectionRulesService); + const workspace = useService(WorkspaceService).workspace; + const pageHelper = usePageHelper(workspace.docCollection); const groups = useLiveData(explorerContextValue.groups$); const isEmpty = groups.length === 0 || (groups.length && groups.every(group => !group.items.length)); + const handleCreateDoc = useCallback( + (event: MouseEvent) => { + pageHelper.createPage(undefined, { + at: inferOpenMode(event), + }); + }, + [pageHelper] + ); + useEffect(() => { const subscription = collectionRulesService .watch({ @@ -65,12 +78,7 @@ const AllDocs = () => { }, [collectionRulesService, explorerContextValue.groups$]); if (isEmpty) { - return ( - <> - - - - ); + return ; } return ( diff --git a/packages/frontend/core/src/mobile/utils/native-user-identifier.spec.ts b/packages/frontend/core/src/mobile/utils/native-user-identifier.spec.ts new file mode 100644 index 0000000000..07b6b72d47 --- /dev/null +++ b/packages/frontend/core/src/mobile/utils/native-user-identifier.spec.ts @@ -0,0 +1,126 @@ +import { LiveData } from '@toeverything/infra'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { NativeUserIdentifierAuthService } from './native-user-identifier'; +import { createNativeUserIdentifierResolver } from './native-user-identifier'; + +const createAuthService = ( + accountId: string | null, + waitForRevalidation: NativeUserIdentifierAuthService['session']['waitForRevalidation'] +): NativeUserIdentifierAuthService => ({ + session: { + account$: new LiveData( + accountId + ? { + id: accountId, + label: 'Test User', + } + : null + ), + waitForRevalidation, + }, +}); + +describe('createNativeUserIdentifierResolver', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test('returns the cached identifier without revalidating', async () => { + const waitForRevalidation = vi.fn().mockResolvedValue(undefined); + const resolveCurrentUserIdentifier = createNativeUserIdentifierResolver(); + const authService = createAuthService('user-id', waitForRevalidation); + + await expect(resolveCurrentUserIdentifier(authService)).resolves.toBe( + 'user-id' + ); + expect(waitForRevalidation).not.toHaveBeenCalled(); + }); + + test('reuses the same in-flight revalidation across concurrent lookups', async () => { + let resolveRevalidation: () => void = () => { + throw new Error('Revalidation promise was not created'); + }; + const waitForRevalidation = vi.fn( + () => + new Promise(resolve => { + resolveRevalidation = () => { + authService.session.account$.value = { + id: 'user-id', + label: 'Test User', + }; + resolve(); + }; + }) + ); + const resolveCurrentUserIdentifier = createNativeUserIdentifierResolver({ + revalidationCooldownMs: 0, + }); + const authService = createAuthService(null, waitForRevalidation); + + const firstLookup = resolveCurrentUserIdentifier(authService); + const secondLookup = resolveCurrentUserIdentifier(authService); + + expect(waitForRevalidation).toHaveBeenCalledTimes(1); + + resolveRevalidation(); + + await expect(firstLookup).resolves.toBe('user-id'); + await expect(secondLookup).resolves.toBe('user-id'); + }); + + test('skips immediate repeat revalidation attempts after an unresolved miss', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-28T00:00:00.000Z')); + + const waitForRevalidation = vi.fn().mockResolvedValue(undefined); + const resolveCurrentUserIdentifier = createNativeUserIdentifierResolver({ + revalidationCooldownMs: 1000, + revalidationTimeoutMs: 1000, + }); + const authService = createAuthService(null, waitForRevalidation); + + await expect(resolveCurrentUserIdentifier(authService)).resolves.toBeNull(); + await expect(resolveCurrentUserIdentifier(authService)).resolves.toBeNull(); + + expect(waitForRevalidation).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1000); + + await expect(resolveCurrentUserIdentifier(authService)).resolves.toBeNull(); + expect(waitForRevalidation).toHaveBeenCalledTimes(2); + }); + + test('keeps revalidation cooldown scoped to each auth service', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-28T00:00:00.000Z')); + + const firstWaitForRevalidation = vi.fn().mockResolvedValue(undefined); + const secondWaitForRevalidation = vi.fn().mockImplementation(() => { + secondAuthService.session.account$.value = { + id: 'second-user-id', + label: 'Second User', + }; + return Promise.resolve(); + }); + const resolveCurrentUserIdentifier = createNativeUserIdentifierResolver({ + revalidationCooldownMs: 1000, + revalidationTimeoutMs: 1000, + }); + const firstAuthService = createAuthService(null, firstWaitForRevalidation); + const secondAuthService = createAuthService( + null, + secondWaitForRevalidation + ); + + await expect( + resolveCurrentUserIdentifier(firstAuthService) + ).resolves.toBeNull(); + await expect(resolveCurrentUserIdentifier(secondAuthService)).resolves.toBe( + 'second-user-id' + ); + + expect(firstWaitForRevalidation).toHaveBeenCalledTimes(1); + expect(secondWaitForRevalidation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/frontend/core/src/mobile/utils/native-user-identifier.ts b/packages/frontend/core/src/mobile/utils/native-user-identifier.ts new file mode 100644 index 0000000000..600361fd04 --- /dev/null +++ b/packages/frontend/core/src/mobile/utils/native-user-identifier.ts @@ -0,0 +1,94 @@ +import type { AuthService } from '../../modules/cloud/services/auth'; + +export interface NativeUserIdentifierAuthService { + session: Pick; +} + +interface NativeUserIdentifierResolverOptions { + revalidationTimeoutMs?: number; + revalidationCooldownMs?: number; +} + +const getAccountIdentifier = (identifier: string | null | undefined) => { + const trimmedIdentifier = identifier?.trim(); + return trimmedIdentifier ? trimmedIdentifier : null; +}; + +export const createNativeUserIdentifierResolver = ({ + revalidationTimeoutMs = 1500, + revalidationCooldownMs = 1000, +}: NativeUserIdentifierResolverOptions = {}) => { + const revalidationStates = new WeakMap< + NativeUserIdentifierAuthService, + { + promise: Promise | null; + lastStartedAt: number; + } + >(); + + const getRevalidationState = ( + authService: NativeUserIdentifierAuthService + ) => { + const existing = revalidationStates.get(authService); + if (existing) { + return existing; + } + + const state = { + promise: null, + lastStartedAt: 0, + }; + revalidationStates.set(authService, state); + return state; + }; + + const waitForSessionRevalidation = async ( + authService: NativeUserIdentifierAuthService + ) => { + const state = getRevalidationState(authService); + const now = Date.now(); + + if (state.promise) { + await state.promise; + return; + } + + if (now - state.lastStartedAt < revalidationCooldownMs) { + return; + } + + state.lastStartedAt = now; + + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + revalidationTimeoutMs + ); + + state.promise = authService.session + .waitForRevalidation(controller.signal) + .catch(() => undefined) + .finally(() => { + clearTimeout(timeoutId); + state.promise = null; + }); + + await state.promise; + }; + + return async (authService: NativeUserIdentifierAuthService) => { + const cachedIdentifier = getAccountIdentifier( + authService.session.account$.value?.id + ); + if (cachedIdentifier) { + return cachedIdentifier; + } + + await waitForSessionRevalidation(authService); + + return getAccountIdentifier(authService.session.account$.value?.id); + }; +}; + +export const getCurrentNativeUserIdentifier = + createNativeUserIdentifierResolver(); diff --git a/packages/frontend/core/src/mobile/views/all-docs/collection/list.tsx b/packages/frontend/core/src/mobile/views/all-docs/collection/list.tsx index 0055eab486..d0b6b4fbaa 100644 --- a/packages/frontend/core/src/mobile/views/all-docs/collection/list.tsx +++ b/packages/frontend/core/src/mobile/views/all-docs/collection/list.tsx @@ -1,16 +1,51 @@ -import { EmptyCollections } from '@affine/core/components/affine/empty'; +import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; import { CollectionService } from '@affine/core/modules/collection'; +import { WorkspaceService } from '@affine/core/modules/workspace'; +import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; +import { useCallback, useState } from 'react'; +import { CollectionRenameDialog } from '../../../components/navigation/nodes/collection/dialog'; +import { MobileAllDocsEmptyState } from '../empty-state'; import { CollectionListItem } from './item'; import { list } from './styles.css'; export const CollectionList = () => { + const t = useI18n(); const collectionService = useService(CollectionService); + const workspace = useService(WorkspaceService).workspace; const collectionMetas = useLiveData(collectionService.collectionMetas$); + const navigateHelper = useNavigateHelper(); + const [showNewCollectionDialog, setShowNewCollectionDialog] = useState(false); + + const handleCreateCollection = useCallback( + (name: string) => { + const id = collectionService.createCollection({ name }); + navigateHelper.jumpToCollection(workspace.id, id); + }, + [collectionService, navigateHelper, workspace.id] + ); if (!collectionMetas.length) { - return ; + return ( + <> + setShowNewCollectionDialog(true)} + /> + t['com.affine.editCollectionName.createTips']()} + /> + + ); } return ( diff --git a/packages/frontend/core/src/mobile/views/all-docs/empty-state.css.ts b/packages/frontend/core/src/mobile/views/all-docs/empty-state.css.ts new file mode 100644 index 0000000000..8d1e393b3b --- /dev/null +++ b/packages/frontend/core/src/mobile/views/all-docs/empty-state.css.ts @@ -0,0 +1,57 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const emptyState = style({ + width: '100%', + minHeight: + 'calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 44px - 84px)', + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '8px 32px calc(env(safe-area-inset-bottom) + 96px)', +}); + +export const illustration = style({ + width: 96, + height: 96, + objectFit: 'contain', + marginBottom: 24, + userSelect: 'none', +}); + +export const copy = style({ + width: '100%', + maxWidth: 280, + textAlign: 'center', + marginBottom: 28, +}); + +export const title = style({ + margin: 0, + fontSize: 21, + lineHeight: '28px', + fontWeight: 700, + color: cssVarV2('text/primary'), +}); + +export const description = style({ + margin: '10px 0 0', + fontSize: 18, + lineHeight: '24px', + fontWeight: 400, + color: cssVarV2('text/secondary'), +}); + +export const actionButton = style({ + minWidth: 164, + borderRadius: 10, + fontSize: 20, + fontWeight: 600, + boxShadow: `0 8px 18px ${cssVarV2('layer/insideBorder/border')}`, +}); + +export const actionIcon = style({ + fontSize: 20, +}); diff --git a/packages/frontend/core/src/mobile/views/all-docs/empty-state.tsx b/packages/frontend/core/src/mobile/views/all-docs/empty-state.tsx new file mode 100644 index 0000000000..e1a9baf67e --- /dev/null +++ b/packages/frontend/core/src/mobile/views/all-docs/empty-state.tsx @@ -0,0 +1,93 @@ +import { Button, ThemedImg } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import { PlusIcon } from '@blocksuite/icons/rc'; +import type { MouseEventHandler } from 'react'; + +import collectionIllustrationDark from '../../../components/affine/empty/assets/collection-list.dark.png'; +import collectionIllustrationLight from '../../../components/affine/empty/assets/collection-list.light.png'; +import docsIllustrationDark from '../../../components/affine/empty/assets/docs.dark.png'; +import docsIllustrationLight from '../../../components/affine/empty/assets/docs.light.png'; +import tagsIllustrationDark from '../../../components/affine/empty/assets/tag-list.dark.png'; +import tagsIllustrationLight from '../../../components/affine/empty/assets/tag-list.light.png'; +import * as styles from './empty-state.css'; + +type EmptyStateType = 'docs' | 'collections' | 'tags'; + +const emptyStateAssets = { + docs: { + illustrationLight: docsIllustrationLight, + illustrationDark: docsIllustrationDark, + }, + collections: { + illustrationLight: collectionIllustrationLight, + illustrationDark: collectionIllustrationDark, + }, + tags: { + illustrationLight: tagsIllustrationLight, + illustrationDark: tagsIllustrationDark, + }, +} satisfies Record< + EmptyStateType, + { + illustrationLight: string; + illustrationDark: string; + } +>; + +const emptyStateI18nKeys = { + docs: { + title: 'com.affine.m.explorer.empty.docs.title', + description: 'com.affine.m.explorer.empty.docs.description', + actionLabel: 'com.affine.m.explorer.empty.docs.action', + }, + collections: { + title: 'com.affine.m.explorer.empty.collections.title', + description: 'com.affine.m.explorer.empty.collections.description', + actionLabel: 'com.affine.m.explorer.empty.collections.action', + }, + tags: { + title: 'com.affine.m.explorer.empty.tags.title', + description: 'com.affine.m.explorer.empty.tags.description', + actionLabel: 'com.affine.m.explorer.empty.tags.action', + }, +} as const; + +export const MobileAllDocsEmptyState = ({ + type, + onAction, +}: { + type: EmptyStateType; + onAction: MouseEventHandler; +}) => { + const t = useI18n(); + const assets = emptyStateAssets[type]; + const copyKeys = emptyStateI18nKeys[type]; + const title = t[copyKeys.title](); + const description = t[copyKeys.description](); + const actionLabel = t[copyKeys.actionLabel](); + + return ( +
+ +
+

{title}

+

{description}

+
+ +
+ ); +}; diff --git a/packages/frontend/core/src/mobile/views/all-docs/index.ts b/packages/frontend/core/src/mobile/views/all-docs/index.ts index 200e45b17e..2ce3021b33 100644 --- a/packages/frontend/core/src/mobile/views/all-docs/index.ts +++ b/packages/frontend/core/src/mobile/views/all-docs/index.ts @@ -1,3 +1,4 @@ export * from './collection'; +export * from './empty-state'; export * from './header'; export * from './tag'; diff --git a/packages/frontend/core/src/mobile/views/all-docs/tag/list.tsx b/packages/frontend/core/src/mobile/views/all-docs/tag/list.tsx index 48a2592608..cf5d6145b1 100644 --- a/packages/frontend/core/src/mobile/views/all-docs/tag/list.tsx +++ b/packages/frontend/core/src/mobile/views/all-docs/tag/list.tsx @@ -1,16 +1,41 @@ -import { EmptyTags } from '@affine/core/components/affine/empty'; import { TagService } from '@affine/core/modules/tag'; import { useLiveData, useService } from '@toeverything/infra'; +import { useCallback, useState } from 'react'; +import { TagRenameDialog } from '../../../components/navigation/nodes/tag/dialog'; +import { MobileAllDocsEmptyState } from '../empty-state'; import { TagItem } from './item'; import { list } from './styles.css'; export const TagList = () => { - const tagList = useService(TagService).tagList; + const tagService = useService(TagService); + const tagList = tagService.tagList; const tags = useLiveData(tagList.tags$); + const [showNewTagDialog, setShowNewTagDialog] = useState(false); + + const handleCreateTag = useCallback( + (name: string, color: string) => { + setShowNewTagDialog(false); + tagList.createTag(name, color); + }, + [tagList] + ); if (!tags.length) { - return ; + return ( + <> + setShowNewTagDialog(true)} + /> + + + ); } return ( diff --git a/packages/frontend/core/src/modules/dialogs/index.ts b/packages/frontend/core/src/modules/dialogs/index.ts index 6d98a569ec..8f88461e78 100644 --- a/packages/frontend/core/src/modules/dialogs/index.ts +++ b/packages/frontend/core/src/modules/dialogs/index.ts @@ -10,8 +10,6 @@ export { WorkspaceDialogService } from './services/workspace-dialog'; export type { DialogComponentProps } from './types'; export function configureDialogModule(framework: Framework) { - framework - .service(GlobalDialogService) - .scope(WorkspaceScope) - .service(WorkspaceDialogService); + framework.service(GlobalDialogService); + framework.scope(WorkspaceScope).service(WorkspaceDialogService); } diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index d7e71e67c5..07addfc60f 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -2,7 +2,7 @@ "ar": 88, "ca": 85, "da": 3, - "de": 96, + "de": 95, "el-GR": 84, "en": 100, "es-AR": 84, @@ -11,18 +11,18 @@ "fa": 84, "fr": 88, "hi": 1, - "it": 86, + "it": 85, "ja": 84, "kk": 91, "ko": 85, "nb-NO": 42, - "pl": 86, + "pl": 85, "pt-BR": 84, "ru": 86, - "sv-SE": 85, + "sv-SE": 84, "tr": 91, "uk": 84, "ur": 91, "zh-Hans": 95, - "zh-Hant": 87 + "zh-Hant": 86 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 5e4efc0ef0..d7c5d7c533 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -8425,6 +8425,42 @@ export function useAFFiNEI18N(): { * `Create Collection` */ ["com.affine.m.explorer.collection.new-dialog-title"](): string; + /** + * `No documents yet` + */ + ["com.affine.m.explorer.empty.docs.title"](): string; + /** + * `Create your first document to start capturing ideas and organizing knowledge.` + */ + ["com.affine.m.explorer.empty.docs.description"](): string; + /** + * `New Document` + */ + ["com.affine.m.explorer.empty.docs.action"](): string; + /** + * `No collections yet` + */ + ["com.affine.m.explorer.empty.collections.title"](): string; + /** + * `Create a collection to organize related content in one place.` + */ + ["com.affine.m.explorer.empty.collections.description"](): string; + /** + * `New Collection` + */ + ["com.affine.m.explorer.empty.collections.action"](): string; + /** + * `No tags yet` + */ + ["com.affine.m.explorer.empty.tags.title"](): string; + /** + * `Add tags to your documents for easier organization and discovery.` + */ + ["com.affine.m.explorer.empty.tags.description"](): string; + /** + * `New Tag` + */ + ["com.affine.m.explorer.empty.tags.action"](): string; /** * `Rename` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 8180215f01..8cebfdfefa 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2096,6 +2096,15 @@ "com.affine.m.explorer.collection.rename": "Rename", "com.affine.m.explorer.collection.rename-menu-title": "Rename Collection", "com.affine.m.explorer.collection.new-dialog-title": "Create Collection", + "com.affine.m.explorer.empty.docs.title": "No documents yet", + "com.affine.m.explorer.empty.docs.description": "Create your first document to start capturing ideas and organizing knowledge.", + "com.affine.m.explorer.empty.docs.action": "New Document", + "com.affine.m.explorer.empty.collections.title": "No collections yet", + "com.affine.m.explorer.empty.collections.description": "Create a collection to organize related content in one place.", + "com.affine.m.explorer.empty.collections.action": "New Collection", + "com.affine.m.explorer.empty.tags.title": "No tags yet", + "com.affine.m.explorer.empty.tags.description": "Add tags to your documents for easier organization and discovery.", + "com.affine.m.explorer.empty.tags.action": "New Tag", "com.affine.m.explorer.doc.rename": "Rename", "com.affine.m.selector.type-doc": "Doc", "com.affine.m.selector.type-tag": "Tag",