mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-06 00:41:39 +08:00
feat(ios): cleanup ui (#15535)
This commit is contained in:
@@ -33,7 +33,6 @@
|
||||
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 */; };
|
||||
@@ -95,7 +94,6 @@
|
||||
50C000112F02000000000000 /* NativeSignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInView.swift; sourceTree = "<group>"; };
|
||||
50C000132F02000000000000 /* NativeSignInHUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInHUD.swift; sourceTree = "<group>"; };
|
||||
50C000152F02000000000000 /* NativeSignInComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignInComponents.swift; sourceTree = "<group>"; };
|
||||
50D000022F03000000000000 /* ColdStartSignInSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColdStartSignInSheetViewController.swift; sourceTree = "<group>"; };
|
||||
50CECF1E2E7C1084004487AA /* AffineResources */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineResources; sourceTree = "<group>"; };
|
||||
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBridgedWindowScript.swift; sourceTree = "<group>"; };
|
||||
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AffineViewController+AIButton.swift"; sourceTree = "<group>"; };
|
||||
@@ -257,7 +255,6 @@
|
||||
9DAE85B72E7BAC3B00DB9F1D /* Plugins */,
|
||||
9D90BE1C2CCB9876006677DB /* AppDelegate.swift */,
|
||||
507513692D1924C600AD60C0 /* RootViewController.swift */,
|
||||
50D000022F03000000000000 /* ColdStartSignInSheetViewController.swift */,
|
||||
9D90BE1B2CCB9876006677DB /* AffineViewController.swift */,
|
||||
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */,
|
||||
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */,
|
||||
@@ -490,7 +487,6 @@
|
||||
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 */,
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -9,72 +9,49 @@ struct AppPaywallCard: View {
|
||||
let palette: AppPaywallPalette
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: layout.cardSectionSpacing) {
|
||||
Text(LocalizedStringKey(plan.headerName))
|
||||
.font(.system(size: layout.cardTitleFontSize, weight: .black))
|
||||
.foregroundStyle(palette.primaryText)
|
||||
.padding(.bottom, layout.sectionSpacing * 0.8)
|
||||
.font(.system(size: layout.cardTitleFontSize, weight: .bold))
|
||||
|
||||
if let priceInfo {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 5) {
|
||||
Text(priceInfo.value)
|
||||
.font(.system(size: layout.priceFontSize, weight: .black))
|
||||
.foregroundStyle(palette.primaryText)
|
||||
.font(.system(size: layout.priceFontSize, weight: .bold))
|
||||
Text(LocalizedStringKey(priceInfo.suffix))
|
||||
.font(.system(size: layout.priceSuffixFontSize, weight: .bold))
|
||||
.foregroundStyle(palette.primaryText)
|
||||
.font(.system(size: layout.priceSuffixFontSize, weight: .medium))
|
||||
}
|
||||
} else {
|
||||
Text("Loading price")
|
||||
.font(.system(size: layout.priceFontSize * 0.55, weight: .bold))
|
||||
Text("Loading price…")
|
||||
.font(.system(size: layout.priceSuffixFontSize, weight: .medium))
|
||||
.foregroundStyle(palette.secondaryText)
|
||||
.frame(height: layout.priceFontSize)
|
||||
.frame(height: layout.priceFontSize, alignment: .leading)
|
||||
}
|
||||
|
||||
Text(LocalizedStringKey(plan.description))
|
||||
.font(.system(size: layout.descriptionFontSize, weight: .medium))
|
||||
.font(.system(size: layout.descriptionFontSize))
|
||||
.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)
|
||||
.overlay(AffineColors.layerBorder.color)
|
||||
|
||||
VStack(alignment: .leading, spacing: layout.featureSpacing) {
|
||||
ForEach(plan.features, id: \.self) { feature in
|
||||
AppPaywallFeatureRow(text: feature, fontSize: layout.featureFontSize, palette: palette)
|
||||
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)
|
||||
.padding(layout.cardPadding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.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)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.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)
|
||||
}
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(palette.cardBorder, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,37 +62,20 @@ private struct AppPaywallFeatureRow: View {
|
||||
let palette: AppPaywallPalette
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: max(13, fontSize - 0.5), weight: .bold))
|
||||
.font(.system(size: fontSize, weight: .semibold))
|
||||
.foregroundStyle(AffineColors.buttonPrimary.color)
|
||||
.padding(.top, 2)
|
||||
.padding(.top, 1)
|
||||
|
||||
Text(LocalizedStringKey(text))
|
||||
.font(.system(size: fontSize, weight: .medium))
|
||||
.font(.system(size: fontSize))
|
||||
.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
|
||||
@@ -124,44 +84,24 @@ struct AppPaywallLegalLinks: View {
|
||||
let onRestore: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ViewThatFits {
|
||||
HStack(spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
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)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
legalButton(title: "Subscription Terms", action: onOpenSubscriptionTerms)
|
||||
legalButton(title: "Restore Purchases", action: onRestore)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12.5, weight: .medium))
|
||||
.foregroundStyle(palette.secondaryText)
|
||||
}
|
||||
|
||||
private var separator: some View {
|
||||
Text(" | ")
|
||||
.foregroundStyle(palette.secondaryText)
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
private func legalButton(title: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(LocalizedStringKey(title))
|
||||
.foregroundStyle(palette.secondaryText)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@@ -12,10 +12,6 @@ struct AppPaywallPalette {
|
||||
isDark ? Color.black.opacity(0.18) : Color.clear
|
||||
}
|
||||
|
||||
var gridOpacity: Double {
|
||||
isDark ? 0.22 : 1
|
||||
}
|
||||
|
||||
var primaryText: Color {
|
||||
AffineColors.textPrimary.color
|
||||
}
|
||||
@@ -25,35 +21,11 @@ struct AppPaywallPalette {
|
||||
}
|
||||
|
||||
var cardBackground: Color {
|
||||
isDark ? AffineColors.layerBackgroundSecondary.color.opacity(0.96) : AffineColors.layerPureWhite.color
|
||||
AffineColors.layerBackgroundPrimary.color.opacity(isDark ? 0.92 : 1)
|
||||
}
|
||||
|
||||
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)
|
||||
AffineColors.layerBorder.color
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,124 +37,67 @@ struct AppPaywallLayout {
|
||||
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
|
||||
max(size.height, 620)
|
||||
}
|
||||
|
||||
var horizontalPadding: CGFloat {
|
||||
max(isLandscape ? 24 : 28, max(safeAreaInsets.leading, safeAreaInsets.trailing) + 18)
|
||||
max(isLandscape ? 32 : 20, max(safeAreaInsets.leading, safeAreaInsets.trailing) + 16)
|
||||
}
|
||||
|
||||
var headerHorizontalPadding: CGFloat {
|
||||
max(18, max(safeAreaInsets.leading, safeAreaInsets.trailing) + 14)
|
||||
var topPadding: CGFloat {
|
||||
max(safeAreaInsets.top + 20, 28)
|
||||
}
|
||||
|
||||
var titleTopSpacing: CGFloat {
|
||||
isCompactLandscape ? 8 : 18
|
||||
}
|
||||
|
||||
var titleBottomSpacing: CGFloat {
|
||||
isCompactLandscape ? 8 : 10
|
||||
var bottomPadding: CGFloat {
|
||||
max(safeAreaInsets.bottom + 8, 16)
|
||||
}
|
||||
|
||||
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
|
||||
isLandscape ? 26 : 30
|
||||
}
|
||||
|
||||
var sectionSpacing: CGFloat {
|
||||
isCompactLandscape ? 10 : 20
|
||||
isLandscape ? 14 : 18
|
||||
}
|
||||
|
||||
var cardTitleFontSize: CGFloat {
|
||||
isLandscape ? 22 : 24
|
||||
}
|
||||
|
||||
var priceFontSize: CGFloat {
|
||||
isLandscape ? 26 : 30
|
||||
}
|
||||
|
||||
var priceSuffixFontSize: CGFloat {
|
||||
isLandscape ? 13 : 15
|
||||
}
|
||||
|
||||
var descriptionFontSize: CGFloat {
|
||||
isLandscape ? 14 : 15
|
||||
}
|
||||
|
||||
var featureFontSize: CGFloat {
|
||||
isLandscape ? 13 : 15
|
||||
}
|
||||
|
||||
var featureSpacing: CGFloat {
|
||||
isLandscape ? 8 : 12
|
||||
}
|
||||
|
||||
var cardSectionSpacing: CGFloat {
|
||||
isLandscape ? 10 : 14
|
||||
}
|
||||
|
||||
var cardPadding: CGFloat {
|
||||
isLandscape ? 18 : 20
|
||||
}
|
||||
|
||||
var buttonHeight: CGFloat {
|
||||
isCompactLandscape ? 50 : 58
|
||||
54
|
||||
}
|
||||
|
||||
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)
|
||||
17
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,10 @@ struct AppPaywallRootView: View {
|
||||
|
||||
ZStack {
|
||||
OnboardingBackground(isIntroPage: false)
|
||||
|
||||
palette.backgroundOverlay
|
||||
.ignoresSafeArea()
|
||||
palette.backgroundOverlay.ignoresSafeArea()
|
||||
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
AppPaywallCarouselPage(
|
||||
AppPaywallPage(
|
||||
bridge: bridge,
|
||||
initialPlan: initialPlan,
|
||||
layout: layout,
|
||||
@@ -39,17 +37,10 @@ struct AppPaywallRootView: View {
|
||||
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)
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
.foregroundStyle(palette.primaryText)
|
||||
}
|
||||
.alert(
|
||||
@@ -70,7 +61,7 @@ struct AppPaywallRootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct AppPaywallCarouselPage: View {
|
||||
private struct AppPaywallPage: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@ObservedObject var bridge: NativePaywallBridge
|
||||
@@ -81,16 +72,8 @@ struct AppPaywallCarouselPage: View {
|
||||
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
|
||||
private let plans = AppPaywallPlan.allCases
|
||||
@State private var selectedPlan: AppPaywallPlan
|
||||
|
||||
init(
|
||||
bridge: NativePaywallBridge,
|
||||
@@ -108,94 +91,33 @@ struct AppPaywallCarouselPage: View {
|
||||
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
|
||||
_selectedPlan = State(initialValue: initialPlan)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
paywallHeader
|
||||
.padding(.top, layout.headerTopPadding)
|
||||
.padding(.horizontal, layout.headerHorizontalPadding)
|
||||
VStack(spacing: layout.sectionSpacing) {
|
||||
Text("Choose your plan")
|
||||
.font(.system(size: layout.titleFontSize, weight: .bold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer(minLength: layout.titleTopSpacing)
|
||||
|
||||
Text("Individual Plans")
|
||||
.font(.system(size: layout.titleFontSize, weight: .black))
|
||||
.foregroundStyle(palette.primaryText)
|
||||
|
||||
Spacer(minLength: layout.titleBottomSpacing)
|
||||
|
||||
GeometryReader { geometry in
|
||||
let cardWidth = layout.cardWidth(for: geometry.size.width)
|
||||
let cardSpacing = layout.cardSpacing
|
||||
let step = cardWidth + cardSpacing
|
||||
let totalOffset = settlingOffset + dragTranslation
|
||||
let normalizedOffset = totalOffset / step
|
||||
|
||||
ZStack {
|
||||
ForEach(visibleSlots, id: \.self) { relativeSlot in
|
||||
let position = CGFloat(relativeSlot) + normalizedOffset
|
||||
let distance = abs(position)
|
||||
let clampedDistance = min(distance, 2)
|
||||
let horizontalDirection: CGFloat = position > 0 ? 1 : (position < 0 ? -1 : 0)
|
||||
let scale = max(0.94, 1 - clampedDistance * 0.038)
|
||||
let opacity = max(0.8, 1 - clampedDistance * 0.11)
|
||||
let sideSpread = clampedDistance * 2
|
||||
let verticalOffset = clampedDistance * (layout.carouselHeight < 320 ? 9 : 16)
|
||||
let rotation = Double(position * 6)
|
||||
let shadowOpacity = Double(max(0.09, 0.2 - clampedDistance * 0.05))
|
||||
let shadowRadius = max(18, 30 - clampedDistance * 5)
|
||||
let shadowYOffset = max(10, 18 - clampedDistance * 2.5)
|
||||
|
||||
AppPaywallCard(
|
||||
plan: plan(for: relativeSlot),
|
||||
priceInfo: bridge.priceInfo(for: plan(for: relativeSlot).planKind),
|
||||
layout: layout,
|
||||
palette: palette
|
||||
)
|
||||
.frame(width: cardWidth, height: geometry.size.height)
|
||||
.scaleEffect(scale)
|
||||
.rotation3DEffect(
|
||||
.degrees(rotation),
|
||||
axis: (x: 0, y: 1, z: 0),
|
||||
perspective: 0.82
|
||||
)
|
||||
.opacity(opacity)
|
||||
.offset(
|
||||
x: position * step + horizontalDirection * sideSpread,
|
||||
y: verticalOffset
|
||||
)
|
||||
.shadow(color: palette.carouselShadow.opacity(shadowOpacity), radius: shadowRadius, x: 0, y: shadowYOffset)
|
||||
.zIndex(10 - distance)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.allowsHitTesting(!isSettling && !bridge.isProcessing)
|
||||
.highPriorityGesture(carouselDragGesture(step: step))
|
||||
}
|
||||
.frame(height: layout.carouselHeight)
|
||||
|
||||
HStack(spacing: 9) {
|
||||
ForEach(0..<plans.count, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index == activePlanIndex ? AffineColors.buttonPrimary.color : palette.inactiveDot)
|
||||
.frame(width: 9, height: 9)
|
||||
Picker("Plan", selection: $selectedPlan) {
|
||||
ForEach(plans, id: \.self) { plan in
|
||||
Text(LocalizedStringKey(plan.headerName)).tag(plan)
|
||||
}
|
||||
}
|
||||
.padding(.top, layout.dotsTopPadding)
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
AppPaywallFooterLinks(palette: palette)
|
||||
.padding(.top, layout.footerTopPadding)
|
||||
.padding(.horizontal, layout.horizontalPadding)
|
||||
AppPaywallCard(
|
||||
plan: selectedPlan,
|
||||
priceInfo: bridge.priceInfo(for: selectedPlan.planKind),
|
||||
layout: layout,
|
||||
palette: palette
|
||||
)
|
||||
|
||||
Text("Subscriptions renew automatically until canceled.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.secondaryText)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
PrimaryButton(
|
||||
title: selectedPlan.buttonTitle,
|
||||
@@ -207,8 +129,12 @@ struct AppPaywallCarouselPage: View {
|
||||
triggerPaywallHaptic()
|
||||
onPurchase(selectedPlan)
|
||||
}
|
||||
.padding(.horizontal, layout.horizontalPadding)
|
||||
.padding(.top, layout.buttonTopPadding)
|
||||
|
||||
Button("Not now", action: onClose)
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(palette.secondaryText)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
.buttonStyle(.plain)
|
||||
|
||||
AppPaywallLegalLinks(
|
||||
palette: palette,
|
||||
@@ -217,97 +143,19 @@ struct AppPaywallCarouselPage: View {
|
||||
onOpenSubscriptionTerms: { openLegalURL("https://affine.pro/terms/#subscription") },
|
||||
onRestore: onRestorePurchases
|
||||
)
|
||||
.padding(.top, layout.legalTopPadding)
|
||||
.padding(.bottom, layout.legalBottomPadding)
|
||||
.padding(.horizontal, layout.horizontalPadding)
|
||||
}
|
||||
.padding(.horizontal, layout.horizontalPadding)
|
||||
.padding(.top, layout.topPadding)
|
||||
.padding(.bottom, layout.bottomPadding)
|
||||
.frame(maxWidth: .infinity, minHeight: layout.pageMinHeight, alignment: .top)
|
||||
.onAppear {
|
||||
currentPlanIndex = selectedIndex(for: initialPlan)
|
||||
selectedPlan = plans[currentPlanIndex]
|
||||
bridge.selectPlan(selectedPlan.planKind)
|
||||
settlingOffset = 0
|
||||
bridge.selectPlan(initialPlan.planKind)
|
||||
}
|
||||
.onChange(of: selectedPlan) { plan in
|
||||
bridge.selectPlan(plan.planKind)
|
||||
}
|
||||
}
|
||||
|
||||
private var paywallHeader: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(palette.closeButtonForeground)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(palette.closeButtonBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private func carouselDragGesture(step: CGFloat) -> some Gesture {
|
||||
DragGesture(minimumDistance: 12)
|
||||
.updating($dragTranslation) { value, state, _ in
|
||||
state = value.translation.width
|
||||
}
|
||||
.onEnded { value in
|
||||
guard !isSettling else { return }
|
||||
|
||||
settlingOffset = value.translation.width
|
||||
let threshold = step * 0.18
|
||||
let projectedOffset = value.predictedEndTranslation.width
|
||||
|
||||
if projectedOffset < -threshold {
|
||||
settleCarousel(step: step, direction: 1)
|
||||
} else if projectedOffset > threshold {
|
||||
settleCarousel(step: step, direction: -1)
|
||||
} else {
|
||||
withAnimation(settleAnimation) {
|
||||
settlingOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func settleCarousel(step: CGFloat, direction: Int) {
|
||||
isSettling = true
|
||||
|
||||
withAnimation(settleAnimation) {
|
||||
settlingOffset = direction > 0 ? -step : step
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + settleDuration) {
|
||||
let nextIndex = wrappedIndex(currentPlanIndex + direction)
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
|
||||
withTransaction(transaction) {
|
||||
currentPlanIndex = nextIndex
|
||||
selectedPlan = plans[nextIndex]
|
||||
settlingOffset = 0
|
||||
}
|
||||
|
||||
isSettling = false
|
||||
}
|
||||
}
|
||||
|
||||
private func selectedIndex(for plan: AppPaywallPlan) -> Int {
|
||||
plans.firstIndex(of: plan) ?? 1
|
||||
}
|
||||
|
||||
private func wrappedIndex(_ index: Int) -> Int {
|
||||
let count = plans.count
|
||||
let remainder = index % count
|
||||
return remainder >= 0 ? remainder : remainder + count
|
||||
}
|
||||
|
||||
private func plan(for relativeSlot: Int) -> AppPaywallPlan {
|
||||
plans[wrappedIndex(currentPlanIndex + relativeSlot)]
|
||||
}
|
||||
|
||||
private func openLegalURL(_ string: String) {
|
||||
guard let url = URL(string: string) else { return }
|
||||
openURL(url)
|
||||
|
||||
@@ -189,13 +189,6 @@ enum AppPaywallPlan: String, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
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."
|
||||
@@ -205,7 +198,11 @@ enum AppPaywallPlan: String, CaseIterable {
|
||||
}
|
||||
|
||||
var buttonTitle: String {
|
||||
"Continue"
|
||||
switch self {
|
||||
case .pro: "Continue with Pro"
|
||||
case .lite: "Continue with Lite"
|
||||
case .ai: "Continue with AI"
|
||||
}
|
||||
}
|
||||
|
||||
var features: [String] {
|
||||
|
||||
@@ -85,29 +85,3 @@ private struct IntroHeroArtwork: View {
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,15 +51,6 @@ struct OnboardingRootView: View {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ import WebKit
|
||||
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"
|
||||
@@ -46,9 +44,7 @@ class RootViewController: UINavigationController {
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
if !presentOnboardingIfNeeded() {
|
||||
runColdStartPaywallFlowIfNeeded()
|
||||
}
|
||||
presentOnboardingIfNeeded()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -67,110 +63,9 @@ class RootViewController: UINavigationController {
|
||||
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."))
|
||||
@@ -190,6 +85,7 @@ class RootViewController: UINavigationController {
|
||||
guard isSignedIn else {
|
||||
return
|
||||
}
|
||||
await dismissOnboardingIfNeeded(onboardingController)
|
||||
|
||||
do {
|
||||
if try await PaywallAuthGuard.hasProSubscription(in: webView) {
|
||||
@@ -248,6 +144,15 @@ class RootViewController: UINavigationController {
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissOnboardingIfNeeded(_ controller: UIViewController?) async {
|
||||
guard let controller, controller.presentingViewController != nil else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
controller.dismiss(animated: false) {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showOnboardingAlert(message: String) {
|
||||
let alert = UIAlertController(
|
||||
|
||||
Reference in New Issue
Block a user