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. - 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. - 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 ### Fixes

View File

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

View File

@ -19,6 +19,7 @@ enum OnboardingConnectionMode: String, CaseIterable {
enum OnboardingStateStore { enum OnboardingStateStore {
private static let completedDefaultsKey = "onboarding.completed" 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 lastModeDefaultsKey = "onboarding.last_mode"
private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time" private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time"
@ -39,10 +40,23 @@ enum OnboardingStateStore {
defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey) 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) { static func markIncomplete(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: Self.completedDefaultsKey) 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? { static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
let raw = defaults.string(forKey: Self.lastModeDefaultsKey)? let raw = defaults.string(forKey: Self.lastModeDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""

View File

@ -6,6 +6,7 @@ import SwiftUI
import UIKit import UIKit
private enum OnboardingStep: Int, CaseIterable { private enum OnboardingStep: Int, CaseIterable {
case intro
case welcome case welcome
case mode case mode
case connect case connect
@ -29,7 +30,8 @@ private enum OnboardingStep: Int, CaseIterable {
var title: String { var title: String {
switch self { switch self {
case .welcome: "Welcome" case .intro: "Welcome"
case .welcome: "Connect Gateway"
case .mode: "Connection Mode" case .mode: "Connection Mode"
case .connect: "Connect" case .connect: "Connect"
case .auth: "Authentication" case .auth: "Authentication"
@ -38,7 +40,7 @@ private enum OnboardingStep: Int, CaseIterable {
} }
var canGoBack: Bool { 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("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("gateway.discovery.domain") private var discoveryDomain: String = "" @AppStorage("gateway.discovery.domain") private var discoveryDomain: String = ""
@AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false @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 selectedMode: OnboardingConnectionMode?
@State private var manualHost: String = "" @State private var manualHost: String = ""
@State private var manualPort: Int = 18789 @State private var manualPort: Int = 18789
@ -58,11 +60,10 @@ struct OnboardingWizardView: View {
@State private var gatewayToken: String = "" @State private var gatewayToken: String = ""
@State private var gatewayPassword: String = "" @State private var gatewayPassword: String = ""
@State private var connectMessage: 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 connectingGatewayID: String?
@State private var issue: GatewayConnectionIssue = .none @State private var issue: GatewayConnectionIssue = .none
@State private var didMarkCompleted = false @State private var didMarkCompleted = false
@State private var didAutoPresentQR = false
@State private var pairingRequestId: String? @State private var pairingRequestId: String?
@State private var discoveryRestartTask: Task<Void, Never>? @State private var discoveryRestartTask: Task<Void, Never>?
@State private var showQRScanner: Bool = false @State private var showQRScanner: Bool = false
@ -74,14 +75,23 @@ struct OnboardingWizardView: View {
let allowSkip: Bool let allowSkip: Bool
let onClose: () -> Void 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 { private var isFullScreenStep: Bool {
self.step == .welcome || self.step == .success self.step == .intro || self.step == .welcome || self.step == .success
} }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { Group {
switch self.step { switch self.step {
case .intro:
self.introStep
case .welcome: case .welcome:
self.welcomeStep self.welcomeStep
case .success: 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 @ViewBuilder
private var welcomeStep: some View { private var welcomeStep: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -303,16 +390,37 @@ struct OnboardingWizardView: View {
.foregroundStyle(.tint) .foregroundStyle(.tint)
.padding(.bottom, 20) .padding(.bottom, 20)
Text("Welcome") Text("Connect Gateway")
.font(.largeTitle.weight(.bold)) .font(.largeTitle.weight(.bold))
.padding(.bottom, 8) .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) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 32) .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() Spacer()
VStack(spacing: 12) { VStack(spacing: 12) {
@ -342,8 +450,7 @@ struct OnboardingWizardView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.horizontal, 24) .padding(.bottom, 48)
.padding(.bottom, 48)
} }
} }
@ -727,6 +834,12 @@ struct OnboardingWizardView: View {
return nil 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() { private func navigateBack() {
guard let target = self.step.previous else { return } guard let target = self.step.previous else { return }
self.connectingGatewayID = nil self.connectingGatewayID = nil
@ -775,10 +888,8 @@ struct OnboardingWizardView: View {
let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword { if !hasSavedGateway, !hasToken, !hasPassword {
self.didAutoPresentQR = true self.statusLine = "No saved pairing found. In your OpenClaw chat, run /pair qr, then scan the code here."
self.statusLine = "No saved pairing found. Scan QR code to connect."
self.showQRScanner = true
} }
} }

View File

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

View File

@ -39,6 +39,35 @@ import Testing
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) #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 { private struct TestDefaults {
var suiteName: String var suiteName: String
var defaults: UserDefaults var defaults: UserDefaults