mirror of https://github.com/openclaw/openclaw.git
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:
parent
61429230b2
commit
496176d738
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) ?? ""
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue