feat(ios): cleanup ui (#15535)

This commit is contained in:
DarkSky
2026-08-27 17:53:36 +08:00
committed by GitHub
parent 612923b55c
commit ca056ae7b9
23 changed files with 376 additions and 1176 deletions
@@ -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'),
},
},
},
});
]);
+14 -10
View File
@@ -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;
/**
+8 -7
View File
@@ -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",