mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-01 06:10:16 +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(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { subHeadlineRegular } from '@toeverything/theme/typography';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const header = style({
|
||||
display: 'flex',
|
||||
@@ -44,8 +45,35 @@ export const docTitleContainer = style({
|
||||
lineHeight: '50px',
|
||||
fontWeight: 700,
|
||||
padding: '38px 0',
|
||||
selectors: {
|
||||
'[data-mobile] &': {
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 8,
|
||||
fontSize: 28,
|
||||
lineHeight: '34px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.38,
|
||||
padding: '24px 0 20px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`[data-mobile] ${docTitleContainer} > span:not(:first-child)`, {
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
letterSpacing: -0.23,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
globalStyle(
|
||||
`[data-mobile] ${docTitleContainer} > [data-testid="date-today-label"]`,
|
||||
{ color: cssVarV2('text/emphasis') }
|
||||
);
|
||||
|
||||
export const placeholder = style({
|
||||
height: 200,
|
||||
width: '100%',
|
||||
@@ -55,6 +83,14 @@ export const placeholder = style({
|
||||
justifyContent: 'center',
|
||||
border: `1px dashed ${cssVarV2.layer.insideBorder.border}`,
|
||||
borderRadius: 8,
|
||||
selectors: {
|
||||
'[data-mobile] &': {
|
||||
minHeight: 180,
|
||||
height: 'auto',
|
||||
border: 0,
|
||||
borderRadius: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const placeholderIcon = style({
|
||||
@@ -68,6 +104,13 @@ export const placeholderIcon = style({
|
||||
justifyContent: 'center',
|
||||
fontSize: 20,
|
||||
marginBottom: 4,
|
||||
selectors: {
|
||||
'[data-mobile] &': {
|
||||
width: 40,
|
||||
height: 40,
|
||||
marginBottom: 12,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const placeholderText = style({
|
||||
@@ -75,4 +118,29 @@ export const placeholderText = style({
|
||||
lineHeight: '22px',
|
||||
marginBottom: 16,
|
||||
color: cssVarV2.text.tertiary,
|
||||
selectors: {
|
||||
'[data-mobile] &': {
|
||||
fontSize: 17,
|
||||
lineHeight: '22px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: -0.43,
|
||||
marginBottom: 0,
|
||||
color: cssVarV2('text/primary'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const placeholderDescription = style([
|
||||
subHeadlineRegular,
|
||||
{
|
||||
display: 'none',
|
||||
color: cssVarV2('text/secondary'),
|
||||
selectors: {
|
||||
'[data-mobile] &': {
|
||||
display: 'block',
|
||||
marginTop: 4,
|
||||
marginBottom: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
WorkbenchService,
|
||||
} from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { TodayIcon } from '@blocksuite/icons/rc';
|
||||
import { PlusIcon, TodayIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Location } from 'history';
|
||||
@@ -64,8 +64,13 @@ export const JournalPlaceholder = ({ dateString }: { dateString: string }) => {
|
||||
<div className={styles.placeholderText}>
|
||||
{t['com.affine.journal.placeholder.title']()}
|
||||
</div>
|
||||
<div className={styles.placeholderDescription}>
|
||||
{t['com.affine.journal.placeholder.description']()}
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size={BUILD_CONFIG.isMobileEdition ? 'extraLarge' : undefined}
|
||||
prefix={BUILD_CONFIG.isMobileEdition ? <PlusIcon /> : undefined}
|
||||
onClick={createJournal}
|
||||
data-testid="confirm-create-journal-button"
|
||||
>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 KiB |
@@ -12,7 +12,6 @@ import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { AboutGroup } from './about';
|
||||
import { AppearanceGroup } from './appearance';
|
||||
import teamPeople from './assets/team-people.png';
|
||||
import { DevicesGroup } from './devices';
|
||||
import { ExperimentalFeatureSetting } from './experimental';
|
||||
import { SettingGroup } from './group';
|
||||
@@ -20,7 +19,7 @@ import { OthersGroup } from './others';
|
||||
import { DeleteAccount } from './others/delete-account';
|
||||
import { RowLayout } from './row.layout';
|
||||
import * as styles from './style.css';
|
||||
import { UserSubscription } from './subscription';
|
||||
import { PlansGroup } from './subscription';
|
||||
import { SwipeDialog } from './swipe-dialog';
|
||||
import { UserProfile } from './user-profile';
|
||||
import { UserUsage } from './user-usage';
|
||||
@@ -31,7 +30,6 @@ const AFFINE_MOBILE_STORE_URL = BUILD_CONFIG.isIOS
|
||||
? 'https://play.google.com/store/apps/details?id=app.affine.pro'
|
||||
: undefined;
|
||||
const AFFINE_DOWNLOAD_URL = 'https://affine.pro/download';
|
||||
const AFFINE_TEAM_URL = 'https://affine.pro/teamhub';
|
||||
|
||||
const SupportGroup = () => {
|
||||
const t = useI18n();
|
||||
@@ -80,29 +78,6 @@ const SupportGroup = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const TeamPromotionCard = () => {
|
||||
const t = useI18n();
|
||||
const urlService = useService(UrlService);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.promoCard}
|
||||
onClick={() => urlService.openExternal(AFFINE_TEAM_URL)}
|
||||
>
|
||||
<span className={styles.promoCardContent}>
|
||||
<span className={styles.promoCardTitle}>
|
||||
{t['com.affine.mobile.setting.promo.title']()}
|
||||
</span>
|
||||
<span className={styles.promoCardDescription}>
|
||||
{t['com.affine.mobile.setting.promo.description']()}
|
||||
</span>
|
||||
</span>
|
||||
<img className={styles.promoCardArt} src={teamPeople} alt="" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const DangerZoneGroup = ({
|
||||
onDeleteFinished,
|
||||
}: {
|
||||
@@ -143,14 +118,13 @@ const MobileSetting = ({
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<UserSubscription />
|
||||
<UserProfile />
|
||||
<UserUsage />
|
||||
<PlansGroup />
|
||||
{status === 'authenticated' ? <DevicesGroup /> : null}
|
||||
<AppearanceGroup />
|
||||
<AboutGroup />
|
||||
<ExperimentalFeatureSetting />
|
||||
<TeamPromotionCard />
|
||||
<SupportGroup />
|
||||
<OthersGroup />
|
||||
<DangerZoneGroup onDeleteFinished={onDeleteFinished} />
|
||||
|
||||
@@ -8,13 +8,21 @@ import * as styles from './style.css';
|
||||
|
||||
export const RowLayout = ({
|
||||
label,
|
||||
description,
|
||||
prefix,
|
||||
children,
|
||||
href,
|
||||
onClick,
|
||||
className,
|
||||
emphasized,
|
||||
}: PropsWithChildren<{
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
prefix?: ReactNode;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
emphasized?: boolean;
|
||||
}>) => {
|
||||
const isLinkRow = !!href && !onClick;
|
||||
const isButtonRow = !!onClick;
|
||||
@@ -42,7 +50,19 @@ export const RowLayout = ({
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className={styles.baseSettingItemName}>{label}</div>
|
||||
{prefix ? <div className={styles.rowPrefix}>{prefix}</div> : null}
|
||||
<div className={styles.rowText}>
|
||||
<div
|
||||
className={clsx(styles.baseSettingItemName, {
|
||||
[styles.emphasizedSettingItemName]: emphasized,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{description ? (
|
||||
<div className={styles.rowDescription}>{description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.baseSettingItemAction}>
|
||||
{children ??
|
||||
(isInteractive ? (
|
||||
@@ -55,9 +75,11 @@ export const RowLayout = ({
|
||||
return (
|
||||
<ConfigModal.Row
|
||||
data-testid="setting-row"
|
||||
className={clsx(styles.baseSettingItem, {
|
||||
[styles.interactiveRow]: isInteractive,
|
||||
})}
|
||||
className={clsx(
|
||||
styles.baseSettingItem,
|
||||
{ [styles.interactiveRow]: isInteractive },
|
||||
className
|
||||
)}
|
||||
onClick={isButtonRow ? handleTrigger : undefined}
|
||||
onKeyDown={isButtonRow ? handleKeyDown : undefined}
|
||||
role={isButtonRow ? 'button' : undefined}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { bodyEmphasized, bodyRegular } from '@toeverything/theme/typography';
|
||||
import {
|
||||
bodyEmphasized,
|
||||
bodyRegular,
|
||||
footnoteRegular,
|
||||
} from '@toeverything/theme/typography';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
@@ -58,6 +62,40 @@ export const baseSettingItemName = style([
|
||||
},
|
||||
]);
|
||||
|
||||
export const emphasizedSettingItemName = style([bodyEmphasized]);
|
||||
|
||||
export const rowText = style({
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
});
|
||||
|
||||
export const rowDescription = style([
|
||||
footnoteRegular,
|
||||
{
|
||||
color: cssVarV2('text/secondary'),
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
export const rowPrefix = style({
|
||||
width: 32,
|
||||
height: 32,
|
||||
flex: '0 0 auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
fontSize: 18,
|
||||
color: cssVarV2('icon/primary'),
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
});
|
||||
|
||||
export const baseSettingItemAction = style([
|
||||
bodyRegular,
|
||||
{
|
||||
@@ -98,94 +136,6 @@ export const linkIcon = style({
|
||||
color: cssVarV2('icon/secondary'),
|
||||
});
|
||||
|
||||
export const promoCard = style({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
border: '0.5px solid rgba(255,255,255,0.14)',
|
||||
borderRadius: 30,
|
||||
padding: '16px 20px 14px',
|
||||
width: '100%',
|
||||
minHeight: 116,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
boxSizing: 'border-box',
|
||||
textAlign: 'left',
|
||||
backgroundColor: cssVarV2('button/primary'),
|
||||
backgroundImage:
|
||||
'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.04) 34%, rgba(255,255,255,0.02) 100%)',
|
||||
color: cssVarV2('button/pureWhiteText'),
|
||||
cursor: 'pointer',
|
||||
isolation: 'isolate',
|
||||
transition: 'transform 180ms ease, box-shadow 180ms ease',
|
||||
boxShadow:
|
||||
'0 10px 20px rgba(13, 40, 99, 0.12), inset 0 1px 0 rgba(255,255,255,0.12)',
|
||||
selectors: {
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0) 48%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.995)',
|
||||
boxShadow:
|
||||
'0 6px 12px rgba(13, 40, 99, 0.1), inset 0 1px 0 rgba(255,255,255,0.1)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const promoCardContent = style({
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
width: '100%',
|
||||
maxWidth: 'none',
|
||||
paddingRight: 0,
|
||||
});
|
||||
|
||||
export const promoCardTitle = style({
|
||||
display: 'block',
|
||||
paddingRight: 72,
|
||||
fontSize: 20,
|
||||
lineHeight: '26px',
|
||||
fontWeight: 600,
|
||||
color: cssVarV2('button/pureWhiteText'),
|
||||
whiteSpace: 'nowrap',
|
||||
textShadow: '0 0.5px 1px rgba(7, 48, 121, 0.12)',
|
||||
});
|
||||
|
||||
export const promoCardDescription = style({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
maxWidth: 'none',
|
||||
paddingRight: 96,
|
||||
fontSize: 16,
|
||||
lineHeight: '21px',
|
||||
color: cssVarV2('button/pureWhiteText'),
|
||||
opacity: 0.94,
|
||||
textShadow: '0 0.5px 1px rgba(7, 48, 121, 0.08)',
|
||||
});
|
||||
|
||||
export const promoCardArt = style({
|
||||
position: 'absolute',
|
||||
right: 14,
|
||||
bottom: 8,
|
||||
width: 80,
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
filter: 'drop-shadow(0 6px 12px rgba(7, 48, 121, 0.12))',
|
||||
opacity: 0.9,
|
||||
});
|
||||
|
||||
export const dangerZoneTitle = style({
|
||||
color: cssVarV2('status/error'),
|
||||
});
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { AuthService, ServerService } from '@affine/core/modules/cloud';
|
||||
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||
import { NativePaywallService } from '@affine/core/modules/paywall';
|
||||
import { UrlService } from '@affine/core/modules/url';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { DiamondIcon, MultiPeopleIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import proDiamond from '../assets/pro-diamond.png';
|
||||
import { SettingGroup } from '../group';
|
||||
import { RowLayout } from '../row.layout';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const UserSubscription = () => {
|
||||
const AFFINE_TEAM_URL = 'https://affine.pro/teamhub';
|
||||
|
||||
export const PlansGroup = () => {
|
||||
const serverService = useService(ServerService);
|
||||
const authService = useService(AuthService);
|
||||
const globalDialogService = useService(GlobalDialogService);
|
||||
const urlService = useService(UrlService);
|
||||
const nativePaywallProvider =
|
||||
useService(NativePaywallService).getNativePaywallProvider();
|
||||
const t = useI18n();
|
||||
@@ -31,30 +36,28 @@ export const UserSubscription = () => {
|
||||
void nativePaywallProvider?.showPaywall('Pro').catch(console.error);
|
||||
}, [globalDialogService, loggedIn, nativePaywallProvider]);
|
||||
|
||||
if (!nativePaywallProvider || supported === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.headerRow}>
|
||||
<div className={styles.perkIconWrapper}>
|
||||
<img className={styles.perkIcon} src={proDiamond} alt="" />
|
||||
</div>
|
||||
<div className={styles.textBlock}>
|
||||
<div className={styles.title}>
|
||||
{t['com.affine.mobile.setting.subscription.title']()}
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{t['com.affine.mobile.setting.subscription.description']()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className={styles.button} variant="primary" onClick={handleOpen}>
|
||||
{t['com.affine.mobile.setting.subscription.button']()}
|
||||
</Button>
|
||||
</div>
|
||||
<SettingGroup title={t['com.affine.mobile.setting.plans.title']()}>
|
||||
{nativePaywallProvider && supported !== false ? (
|
||||
<RowLayout
|
||||
className={styles.planRow}
|
||||
emphasized
|
||||
prefix={<DiamondIcon />}
|
||||
label={t['com.affine.mobile.setting.subscription.title']()}
|
||||
description={t[
|
||||
'com.affine.mobile.setting.subscription.description'
|
||||
]()}
|
||||
onClick={handleOpen}
|
||||
/>
|
||||
) : null}
|
||||
<RowLayout
|
||||
className={styles.planRow}
|
||||
emphasized
|
||||
prefix={<MultiPeopleIcon />}
|
||||
label={t['com.affine.mobile.setting.promo.title']()}
|
||||
description={t['com.affine.mobile.setting.promo.description']()}
|
||||
onClick={() => urlService.openExternal(AFFINE_TEAM_URL)}
|
||||
/>
|
||||
</SettingGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,76 +1,5 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 18,
|
||||
borderRadius: 24,
|
||||
padding: '24px',
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
});
|
||||
|
||||
export const headerRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const perkIconWrapper = style({
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const textBlock = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const title = style({
|
||||
fontSize: '18px',
|
||||
lineHeight: '22px',
|
||||
fontWeight: 600,
|
||||
color: cssVarV2('text/primary'),
|
||||
textAlign: 'left',
|
||||
});
|
||||
|
||||
export const perkIcon = style({
|
||||
width: 18,
|
||||
height: 18,
|
||||
flexShrink: 0,
|
||||
objectFit: 'contain',
|
||||
});
|
||||
|
||||
export const description = style({
|
||||
fontSize: '14px',
|
||||
lineHeight: '19px',
|
||||
fontWeight: 400,
|
||||
color: cssVarV2('text/secondary'),
|
||||
maxWidth: 250,
|
||||
});
|
||||
|
||||
export const button = style({
|
||||
width: '100%',
|
||||
minHeight: 48,
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
borderRadius: 999,
|
||||
export const planRow = style({
|
||||
minHeight: 68,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bodyEmphasized } from '@toeverything/theme/typography';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
@@ -13,13 +14,10 @@ export const header = style({
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
});
|
||||
|
||||
export const headerTitle = style({
|
||||
color: cssVarV2('text/primary'),
|
||||
fontSize: 17,
|
||||
lineHeight: '22px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: -0.43,
|
||||
});
|
||||
export const headerTitle = style([
|
||||
bodyEmphasized,
|
||||
{ color: cssVarV2('text/primary') },
|
||||
]);
|
||||
|
||||
export const journalDatePicker = style({
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
headlineRegular,
|
||||
subHeadlineRegular,
|
||||
} from '@toeverything/theme/typography';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
@@ -14,10 +18,10 @@ export const emptyState = style({
|
||||
});
|
||||
|
||||
export const illustration = style({
|
||||
width: 96,
|
||||
height: 96,
|
||||
width: 80,
|
||||
height: 80,
|
||||
objectFit: 'contain',
|
||||
marginBottom: 24,
|
||||
marginBottom: 16,
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
@@ -25,31 +29,27 @@ export const copy = style({
|
||||
width: '100%',
|
||||
maxWidth: 280,
|
||||
textAlign: 'center',
|
||||
marginBottom: 28,
|
||||
marginBottom: 20,
|
||||
});
|
||||
|
||||
export const title = style({
|
||||
margin: 0,
|
||||
fontSize: 21,
|
||||
lineHeight: '28px',
|
||||
fontWeight: 700,
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
export const title = style([
|
||||
headlineRegular,
|
||||
{
|
||||
margin: 0,
|
||||
color: cssVarV2('text/primary'),
|
||||
},
|
||||
]);
|
||||
|
||||
export const description = style({
|
||||
margin: '10px 0 0',
|
||||
fontSize: 18,
|
||||
lineHeight: '24px',
|
||||
fontWeight: 400,
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
export const description = style([
|
||||
subHeadlineRegular,
|
||||
{
|
||||
margin: '6px 0 0',
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
]);
|
||||
|
||||
export const actionButton = style({
|
||||
minWidth: 164,
|
||||
borderRadius: 10,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
boxShadow: `0 8px 18px ${cssVarV2('layer/insideBorder/border')}`,
|
||||
borderRadius: 8,
|
||||
});
|
||||
|
||||
export const actionIcon = style({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bodyRegular } from '@toeverything/theme/typography';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
@@ -11,6 +12,7 @@ export const header = style({
|
||||
top: 0,
|
||||
backgroundColor: cssVarV2('layer/background/mobile/primary'),
|
||||
zIndex: 1,
|
||||
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
});
|
||||
export const headerSpace = style([basicHeader]);
|
||||
export const headerContent = style([
|
||||
@@ -26,18 +28,32 @@ export const headerContent = style([
|
||||
|
||||
export const tabs = style({
|
||||
height: 44,
|
||||
gap: 16,
|
||||
gap: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const tab = style({
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
lineHeight: '28px',
|
||||
color: cssVarV2('text/tertiary'),
|
||||
selectors: {
|
||||
'&[data-active="true"]': {
|
||||
color: cssVarV2('text/primary'),
|
||||
export const tab = style([
|
||||
bodyRegular,
|
||||
{
|
||||
position: 'relative',
|
||||
height: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: cssVarV2('tab/fontColor/default'),
|
||||
selectors: {
|
||||
'&[data-active="true"]': {
|
||||
fontWeight: 600,
|
||||
color: cssVarV2('tab/fontColor/active'),
|
||||
},
|
||||
'&[data-active="true"]::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
height: 2,
|
||||
background: cssVarV2('tab/divider/indicator'),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
@@ -2786,11 +2786,15 @@ export function useAFFiNEI18N(): {
|
||||
*/
|
||||
["com.affine.journal.updated-today"](): string;
|
||||
/**
|
||||
* `No Journal`
|
||||
* `No journal for this day`
|
||||
*/
|
||||
["com.affine.journal.placeholder.title"](): string;
|
||||
/**
|
||||
* `Create Daily Journal`
|
||||
* `Create one to start writing.`
|
||||
*/
|
||||
["com.affine.journal.placeholder.description"](): string;
|
||||
/**
|
||||
* `Create journal`
|
||||
*/
|
||||
["com.affine.journal.placeholder.create"](): string;
|
||||
/**
|
||||
@@ -3104,23 +3108,23 @@ export function useAFFiNEI18N(): {
|
||||
*/
|
||||
["com.affine.mobile.setting.danger-zone.title"](): string;
|
||||
/**
|
||||
* `Collaborate seamlessly with AFFiNE team, available in Cloud and Self-Hosted versions.`
|
||||
* `Plans`
|
||||
*/
|
||||
["com.affine.mobile.setting.plans.title"](): string;
|
||||
/**
|
||||
* `Collaborate in Cloud or Self-Hosted.`
|
||||
*/
|
||||
["com.affine.mobile.setting.promo.description"](): string;
|
||||
/**
|
||||
* `AFFiNE for team and more`
|
||||
* `AFFiNE for teams`
|
||||
*/
|
||||
["com.affine.mobile.setting.promo.title"](): string;
|
||||
/**
|
||||
* `Go Pro`
|
||||
*/
|
||||
["com.affine.mobile.setting.subscription.button"](): string;
|
||||
/**
|
||||
* `Unlimited space for your notes and boards.`
|
||||
* `More cloud storage and advanced features.`
|
||||
*/
|
||||
["com.affine.mobile.setting.subscription.description"](): string;
|
||||
/**
|
||||
* `Unlock Pro Features`
|
||||
* `AFFiNE Pro`
|
||||
*/
|
||||
["com.affine.mobile.setting.subscription.title"](): string;
|
||||
/**
|
||||
|
||||
@@ -688,8 +688,9 @@
|
||||
"com.affine.journal.daily-count-created-empty-tips": "You haven't created anything yet",
|
||||
"com.affine.journal.daily-count-updated-empty-tips": "You haven't updated anything yet",
|
||||
"com.affine.journal.updated-today": "Updated",
|
||||
"com.affine.journal.placeholder.title": "No Journal",
|
||||
"com.affine.journal.placeholder.create": "Create Daily Journal",
|
||||
"com.affine.journal.placeholder.title": "No journal for this day",
|
||||
"com.affine.journal.placeholder.description": "Create one to start writing.",
|
||||
"com.affine.journal.placeholder.create": "Create journal",
|
||||
"com.affine.just-now": "Just now",
|
||||
"com.affine.keyboardShortcuts.alignCenter": "Align center",
|
||||
"com.affine.keyboardShortcuts.alignLeft": "Align left",
|
||||
@@ -767,11 +768,11 @@
|
||||
"com.affine.mobile.setting.others.website": "Official website",
|
||||
"com.affine.mobile.setting.others.delete-account": "Delete Account",
|
||||
"com.affine.mobile.setting.danger-zone.title": "Danger Zone",
|
||||
"com.affine.mobile.setting.promo.description": "Collaborate seamlessly with AFFiNE team, available in Cloud and Self-Hosted versions.",
|
||||
"com.affine.mobile.setting.promo.title": "AFFiNE for team and more",
|
||||
"com.affine.mobile.setting.subscription.button": "Go Pro",
|
||||
"com.affine.mobile.setting.subscription.description": "Unlimited space for your notes and boards.",
|
||||
"com.affine.mobile.setting.subscription.title": "Unlock Pro Features",
|
||||
"com.affine.mobile.setting.plans.title": "Plans",
|
||||
"com.affine.mobile.setting.promo.description": "Collaborate in Cloud or Self-Hosted.",
|
||||
"com.affine.mobile.setting.promo.title": "AFFiNE for teams",
|
||||
"com.affine.mobile.setting.subscription.description": "More cloud storage and advanced features.",
|
||||
"com.affine.mobile.setting.subscription.title": "AFFiNE Pro",
|
||||
"com.affine.mobile.setting.support.invite": "Invite a friend",
|
||||
"com.affine.mobile.setting.support.invite-message": "Check out AFFiNE for notes, whiteboards, docs, and AI.",
|
||||
"com.affine.mobile.setting.support.rate": "Rate AFFiNE",
|
||||
|
||||
Reference in New Issue
Block a user