Files
upstream-affine/packages/frontend/apps/ios/App/App/Onboarding/OnboardingRootView.swift
upstream-mirror 50f788e799
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:31:07 +05:00

337 lines
8.2 KiB
Swift

import AffineResources
import SwiftUI
import UIKit
struct OnboardingRootView: View {
let onCompleteOnboarding: () -> Void
@State private var pageIndex = 0
@State private var selectedRole: OnboardingRole?
private let pages = OnboardingPage.all
private var isIntroPage: Bool {
pages[pageIndex].isIntro
}
private var isRolePage: Bool {
if case .role = pages[pageIndex] { return true }
return false
}
private var shouldShowHeader: Bool {
!isIntroPage
}
private var isNextEnabled: Bool {
!isRolePage || selectedRole != nil
}
private var shouldShowContinueLabel: Bool {
pageIndex == pages.count - 1
}
private var progressPageCount: Int {
pages.filter(\.showsProgress).count
}
private var progressIndex: Int {
let visibleProgressCount = pages.prefix(pageIndex + 1).filter(\.showsProgress).count
return max(visibleProgressCount - 1, 0)
}
var body: some View {
GeometryReader { geometry in
let layout = OnboardingLayout(size: geometry.size, safeAreaInsets: geometry.safeAreaInsets)
ZStack {
Color.clear
.ignoresSafeArea()
OnboardingBackground(isIntroPage: isIntroPage)
onboardingContent(layout: layout)
}
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.foregroundStyle(AffineColors.textPrimary.color)
}
@ViewBuilder
private func onboardingContent(layout: OnboardingLayout) -> some View {
if layout.usesScrollableContainer {
ScrollView(.vertical, showsIndicators: false) {
onboardingStack(layout: layout)
.frame(maxWidth: layout.maxContentWidth)
.frame(maxWidth: .infinity)
}
} else {
onboardingStack(layout: layout)
.frame(maxWidth: layout.maxContentWidth)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func onboardingStack(layout: OnboardingLayout) -> some View {
VStack(spacing: 0) {
VStack(spacing: 0) {
if shouldShowHeader {
header
}
pageContainer
.frame(height: layout.pageContainerHeight(for: pages[pageIndex]))
}
.offset(y: isIntroPage ? 0 : -30)
footer
.offset(y: 30)
}
}
private var header: some View {
let sideInset: CGFloat = 12
let backButtonSize: CGFloat = 40
let pageDotsHorizontalInset: CGFloat = 65
return ZStack(alignment: .leading) {
PageDots(count: progressPageCount, selectedIndex: progressIndex)
.frame(maxWidth: .infinity)
.padding(.horizontal, pageDotsHorizontalInset)
Button {
goBack()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.frame(width: backButtonSize, height: backButtonSize)
.foregroundStyle(AffineColors.textSecondary.color)
}
.padding(.leading, sideInset)
.opacity(pageIndex > 0 ? 1 : 0)
.disabled(pageIndex == 0)
}
.frame(height: 44)
}
private var pageContainer: some View {
pageView(for: pages[pageIndex])
.padding(.horizontal, horizontalPadding(for: pages[pageIndex]))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(pageIndex)
.transition(.opacity)
.animation(.spring(response: 0.30, dampingFraction: 0.88), value: pageIndex)
}
@ViewBuilder
private func pageView(for page: OnboardingPage) -> some View {
switch page {
case .intro:
IntroPage()
case .role:
RolePage(selectedRole: $selectedRole)
case let .feature(feature):
FeaturePage(feature: feature)
}
}
private var footer: some View {
VStack(spacing: 10) {
if isIntroPage {
IntroFooter {
triggerOnboardingHaptic()
goNext()
}
} else {
PrimaryButton(
title: shouldShowContinueLabel ? "Continue" : "Next",
isEnabled: isNextEnabled,
fontSize: 18
) {
triggerOnboardingHaptic()
goNext()
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, isIntroPage ? 22 : 10)
}
private func goNext() {
guard isNextEnabled else { return }
guard pageIndex < pages.count - 1 else {
onCompleteOnboarding()
return
}
pageIndex += 1
}
private func goBack() {
guard pageIndex > 0 else { return }
pageIndex -= 1
}
private func horizontalPadding(for page: OnboardingPage) -> CGFloat {
if page.isIntro {
return 0
}
return 20
}
private func triggerOnboardingHaptic() {
let generator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
generator.impactOccurred()
}
}
private struct OnboardingLayout {
let size: CGSize
let safeAreaInsets: EdgeInsets
private var isLandscape: Bool {
size.width > size.height
}
private var isPadLike: Bool {
min(size.width, size.height) >= 700
}
private var isCompactHeight: Bool {
size.height < 560
}
private var horizontalSafeArea: CGFloat {
safeAreaInsets.leading + safeAreaInsets.trailing
}
var usesScrollableContainer: Bool {
isLandscape && !isPadLike && isCompactHeight
}
var maxContentWidth: CGFloat {
let availableWidth = max(size.width - horizontalSafeArea, 320)
if isPadLike {
return min(availableWidth, 620)
}
if usesScrollableContainer {
return min(availableWidth, 520)
}
return min(availableWidth, 430)
}
func pageContainerHeight(for page: OnboardingPage) -> CGFloat {
if usesScrollableContainer {
return compactPageHeight(for: page)
}
let reservedHeight: CGFloat = page.isIntro ? 72 : 116
let availableHeight = size.height - safeAreaInsets.top - safeAreaInsets.bottom - reservedHeight
return max(360, availableHeight)
}
private func compactPageHeight(for page: OnboardingPage) -> CGFloat {
switch page {
case .intro:
return 620
case .role:
return 560
case .feature:
return 540
}
}
}
private enum OnboardingPage {
case intro
case role
case feature(OnboardingFeature)
static let all: [OnboardingPage] = [
.intro,
.role,
.feature(.clearDocs),
.feature(.biggerPicture),
.feature(.multipleViews),
.feature(.everyDevice),
.feature(.ai),
]
var isIntro: Bool {
if case .intro = self { return true }
return false
}
var showsProgress: Bool {
switch self {
case .role, .feature:
return true
case .intro:
return false
}
}
}
enum OnboardingRole: String, CaseIterable {
case student = "Student"
case educator = "Educator"
case professional = "Professional"
case other = "Other"
var title: String {
rawValue
}
var assetName: String {
switch self {
case .student: "OnboardingRoleStudent"
case .educator: "OnboardingRoleEducator"
case .professional: "OnboardingRoleProfessional"
case .other: "OnboardingRoleOther"
}
}
}
enum OnboardingFeature: CaseIterable {
case clearDocs
case biggerPicture
case multipleViews
case everyDevice
case ai
var titleSegments: [(text: String, isHighlighted: Bool)] {
switch self {
case .clearDocs:
[("Turn Ideas into ", false), ("Clear Docs", true)]
case .biggerPicture:
[("See the ", false), ("Bigger Picture", true)]
case .multipleViews:
[("One Idea, ", false), ("Multiple Views", true)]
case .everyDevice:
[("Your Work, ", false), ("Every Device", true)]
case .ai:
[("Get More Done with ", false), ("AI", true)]
}
}
var subtitle: String {
switch self {
case .clearDocs: "Capture notes, meetings, and plans in one focused workspace."
case .biggerPicture: "Connect ideas, plans, and knowledge visually."
case .multipleViews: "Bring notes, whiteboards, and projects together in one workspace."
case .everyDevice: "Stay synced across desktop, web, and mobile devices."
case .ai: "Write, summarize, and structure ideas in seconds."
}
}
var assetName: String {
switch self {
case .clearDocs: "OnboardingFeatureClearDocs"
case .biggerPicture: "OnboardingFeatureBiggerPicture"
case .multipleViews: "OnboardingFeatureMultipleViews"
case .everyDevice: "OnboardingFeatureEveryDevice"
case .ai: "OnboardingFeatureAI"
}
}
}