feat(ios): add onboarding welcome pager (#45054)

* feat(ios): add onboarding welcome pager

* feat(ios): add onboarding welcome pager (#45054) (thanks @ngutman)
This commit is contained in:
Nimrod Gutman 2026-03-13 14:24:15 +02:00 committed by GitHub
parent 61429230b2
commit 496176d738
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 172 additions and 14 deletions

View File

@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
- Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus.
- Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei.
- iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show `/pair qr` instructions on the connect step. (#45054) Thanks @ngutman.
### Fixes

View File

@ -189,6 +189,7 @@ final class ShareViewController: UIViewController {
try await gateway.connect(
url: url,
token: config.token,
bootstrapToken: nil,
password: config.password,
connectOptions: makeOptions("openclaw-ios"),
sessionBox: nil,
@ -208,6 +209,7 @@ final class ShareViewController: UIViewController {
try await gateway.connect(
url: url,
token: config.token,
bootstrapToken: nil,
password: config.password,
connectOptions: makeOptions("moltbot-ios"),
sessionBox: nil,

View File

@ -19,6 +19,7 @@ enum OnboardingConnectionMode: String, CaseIterable {
enum OnboardingStateStore {
private static let completedDefaultsKey = "onboarding.completed"
private static let firstRunIntroSeenDefaultsKey = "onboarding.first_run_intro_seen"
private static let lastModeDefaultsKey = "onboarding.last_mode"
private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time"
@ -39,10 +40,23 @@ enum OnboardingStateStore {
defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey)
}
static func shouldPresentFirstRunIntro(defaults: UserDefaults = .standard) -> Bool {
!defaults.bool(forKey: Self.firstRunIntroSeenDefaultsKey)
}
static func markFirstRunIntroSeen(defaults: UserDefaults = .standard) {
defaults.set(true, forKey: Self.firstRunIntroSeenDefaultsKey)
}
static func markIncomplete(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: Self.completedDefaultsKey)
}
static func reset(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: Self.completedDefaultsKey)
defaults.set(false, forKey: Self.firstRunIntroSeenDefaultsKey)
}
static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
let raw = defaults.string(forKey: Self.lastModeDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""

View File

@ -6,6 +6,7 @@ import SwiftUI
import UIKit
private enum OnboardingStep: Int, CaseIterable {
case intro
case welcome
case mode
case connect
@ -29,7 +30,8 @@ private enum OnboardingStep: Int, CaseIterable {
var title: String {
switch self {
case .welcome: "Welcome"
case .intro: "Welcome"
case .welcome: "Connect Gateway"
case .mode: "Connection Mode"
case .connect: "Connect"
case .auth: "Authentication"
@ -38,7 +40,7 @@ private enum OnboardingStep: Int, CaseIterable {
}
var canGoBack: Bool {
self != .welcome && self != .success
self != .intro && self != .welcome && self != .success
}
}
@ -49,7 +51,7 @@ struct OnboardingWizardView: View {
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("gateway.discovery.domain") private var discoveryDomain: String = ""
@AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false
@State private var step: OnboardingStep = .welcome
@State private var step: OnboardingStep
@State private var selectedMode: OnboardingConnectionMode?
@State private var manualHost: String = ""
@State private var manualPort: Int = 18789
@ -58,11 +60,10 @@ struct OnboardingWizardView: View {
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
@State private var connectMessage: String?
@State private var statusLine: String = "Scan the QR code from your gateway to connect."
@State private var statusLine: String = "In your OpenClaw chat, run /pair qr, then scan the code here."
@State private var connectingGatewayID: String?
@State private var issue: GatewayConnectionIssue = .none
@State private var didMarkCompleted = false
@State private var didAutoPresentQR = false
@State private var pairingRequestId: String?
@State private var discoveryRestartTask: Task<Void, Never>?
@State private var showQRScanner: Bool = false
@ -74,14 +75,23 @@ struct OnboardingWizardView: View {
let allowSkip: Bool
let onClose: () -> Void
init(allowSkip: Bool, onClose: @escaping () -> Void) {
self.allowSkip = allowSkip
self.onClose = onClose
_step = State(
initialValue: OnboardingStateStore.shouldPresentFirstRunIntro() ? .intro : .welcome)
}
private var isFullScreenStep: Bool {
self.step == .welcome || self.step == .success
self.step == .intro || self.step == .welcome || self.step == .success
}
var body: some View {
NavigationStack {
Group {
switch self.step {
case .intro:
self.introStep
case .welcome:
self.welcomeStep
case .success:
@ -293,6 +303,83 @@ struct OnboardingWizardView: View {
}
}
@ViewBuilder
private var introStep: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "iphone.gen3")
.font(.system(size: 60, weight: .semibold))
.foregroundStyle(.tint)
.padding(.bottom, 18)
Text("Welcome to OpenClaw")
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.bottom, 10)
Text("Turn this iPhone into a secure OpenClaw node for chat, voice, camera, and device tools.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.bottom, 24)
VStack(alignment: .leading, spacing: 14) {
Label("Connect to your gateway", systemImage: "link")
Label("Choose device permissions", systemImage: "hand.raised")
Label("Use OpenClaw from your phone", systemImage: "message.fill")
}
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
}
.padding(.horizontal, 24)
.padding(.bottom, 16)
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title3.weight(.semibold))
.foregroundStyle(.orange)
.frame(width: 24)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
Text("Security notice")
.font(.headline)
Text(
"The connected OpenClaw agent can use device capabilities you enable, such as camera, microphone, photos, contacts, calendar, and location. Continue only if you trust the gateway and agent you connect to.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
}
.padding(.horizontal, 24)
Spacer()
Button {
self.advanceFromIntro()
} label: {
Text("Continue")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal, 24)
.padding(.bottom, 48)
}
}
@ViewBuilder
private var welcomeStep: some View {
VStack(spacing: 0) {
@ -303,16 +390,37 @@ struct OnboardingWizardView: View {
.foregroundStyle(.tint)
.padding(.bottom, 20)
Text("Welcome")
Text("Connect Gateway")
.font(.largeTitle.weight(.bold))
.padding(.bottom, 8)
Text("Connect to your OpenClaw gateway")
Text("Scan a QR code from your OpenClaw gateway or continue with manual setup.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
VStack(alignment: .leading, spacing: 8) {
Text("How to pair")
.font(.headline)
Text("In your OpenClaw chat, run")
.font(.footnote)
.foregroundStyle(.secondary)
Text("/pair qr")
.font(.system(.footnote, design: .monospaced).weight(.semibold))
Text("Then scan the QR code here to connect this iPhone.")
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
}
.padding(.horizontal, 24)
.padding(.top, 20)
Spacer()
VStack(spacing: 12) {
@ -342,8 +450,7 @@ struct OnboardingWizardView: View {
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
.padding(.horizontal, 24)
.padding(.bottom, 48)
.padding(.bottom, 48)
}
}
@ -727,6 +834,12 @@ struct OnboardingWizardView: View {
return nil
}
private func advanceFromIntro() {
OnboardingStateStore.markFirstRunIntroSeen()
self.statusLine = "In your OpenClaw chat, run /pair qr, then scan the code here."
self.step = .welcome
}
private func navigateBack() {
guard let target = self.step.previous else { return }
self.connectingGatewayID = nil
@ -775,10 +888,8 @@ struct OnboardingWizardView: View {
let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword {
self.didAutoPresentQR = true
self.statusLine = "No saved pairing found. Scan QR code to connect."
self.showQRScanner = true
if !hasSavedGateway, !hasToken, !hasPassword {
self.statusLine = "No saved pairing found. In your OpenClaw chat, run /pair qr, then scan the code here."
}
}

View File

@ -1008,6 +1008,7 @@ struct SettingsTab: View {
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
GatewaySettingsStore.clearLastGatewayConnection()
OnboardingStateStore.reset()
// RootCanvas also short-circuits onboarding when these are true.
self.onboardingComplete = false

View File

@ -39,6 +39,35 @@ import Testing
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
}
@Test func firstRunIntroDefaultsToVisibleThenPersists() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
#expect(OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
OnboardingStateStore.markFirstRunIntroSeen(defaults: defaults)
#expect(!OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
}
@Test @MainActor func resetClearsCompletionAndIntroSeen() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
OnboardingStateStore.markCompleted(mode: .homeNetwork, defaults: defaults)
OnboardingStateStore.markFirstRunIntroSeen(defaults: defaults)
OnboardingStateStore.reset(defaults: defaults)
let appModel = NodeAppModel()
appModel.gatewayServerName = nil
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
#expect(OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
#expect(OnboardingStateStore.lastMode(defaults: defaults) == .homeNetwork)
}
private struct TestDefaults {
var suiteName: String
var defaults: UserDefaults