mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 11:46:09 +00:00
* feat(apps): Android, iPhone, and Watch approval clients Squash-rebased #103912 segment onto the deep-links tip on current main. Native approval surfaces: iOS approval presentation with gateway-switch lease preservation and resolution fencing, watchOS inbox + approval actions with shipped-shape payload codec, Android approval notices with publication-tokened dismissal. Native i18n inventory regenerated. (cherry picked from commit 428a76670ffeede54248b7bd7aa4438e2589851b) (cherry picked from commit 80225d5707c3645eeea5435f266131037b50ede6) (cherry picked from commit 2a23b714dc30d773a294aa45adc617e46b80732e) (cherry picked from commit 9ff1153827769e2cbab7c11c153f0f8634662c28) (cherry picked from commit 5b25723525bd562e5f97478af492f7b46ad30fd1) (cherry picked from commit 8c80e8467b5fe89aad0d4c74a0573f1600ed3af9) (cherry picked from commit ad4037bc9846bf6f082b41c129ee68aa344576c3) (cherry picked from commit fdf767dd662cff3c8a5a6d571f38f153410651ca) (cherry picked from commit 00c120376ffd992ea68ce29254a9bc0a25ed1740) (cherry picked from commitf36a95213e) (cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac) (cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235) (cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1) (cherry picked from commit2a60619722) (cherry picked from commit6f0c386567) (cherry picked from commit784a5857b7) (cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db) * fix(apps): harden approval reconciliation and watch states
1365 lines
53 KiB
Swift
1365 lines
53 KiB
Swift
import OpenClawKit
|
||
import SwiftUI
|
||
|
||
/// iOS Settings-style icon: white glyph on a solid rounded-square, sized for a List row.
|
||
struct SettingsIcon: View {
|
||
let systemName: String
|
||
let color: Color
|
||
|
||
var body: some View {
|
||
Image(systemName: self.systemName)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 28, height: 28)
|
||
.background(RoundedRectangle(cornerRadius: 7, style: .continuous).fill(self.color))
|
||
}
|
||
}
|
||
|
||
private struct AppearanceSettingsRow: View {
|
||
@Environment(AppAppearanceModel.self) private var appearanceModel
|
||
|
||
private var preference: AppAppearancePreference {
|
||
self.appearanceModel.preference
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationLink {
|
||
AppearanceSettingsScreen()
|
||
} label: {
|
||
self.rowLabel
|
||
}
|
||
.accessibilityIdentifier("settings-appearance-row")
|
||
.accessibilityLabel("Appearance")
|
||
.accessibilityValue(self.preference.label)
|
||
.accessibilityHint("Choose system, light, or dark appearance")
|
||
}
|
||
|
||
private var rowLabel: some View {
|
||
HStack(spacing: 12) {
|
||
ProIconBadge(
|
||
systemName: "circle.lefthalf.filled",
|
||
color: .secondary)
|
||
|
||
Text("Appearance")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
|
||
Spacer(minLength: 8)
|
||
|
||
Text(self.preference.label)
|
||
.font(OpenClawType.subhead)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct AppearanceSettingsScreen: View {
|
||
@Environment(AppAppearanceModel.self) private var appearanceModel
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
List {
|
||
Section {
|
||
ForEach(AppAppearancePreference.allCases) { preference in
|
||
Button {
|
||
self.select(preference)
|
||
} label: {
|
||
Label {
|
||
HStack {
|
||
Text(preference.label)
|
||
.font(OpenClawType.body)
|
||
Spacer()
|
||
if preference == self.appearanceModel.preference {
|
||
Image(systemName: "checkmark")
|
||
.foregroundStyle(OpenClawBrand.accent)
|
||
}
|
||
}
|
||
} icon: {
|
||
Image(systemName: preference.systemImage)
|
||
}
|
||
}
|
||
.foregroundStyle(.primary)
|
||
.accessibilityIdentifier("settings-appearance-\(preference.rawValue)")
|
||
.accessibilityValue(
|
||
preference == self.appearanceModel.preference ? "Selected" : "")
|
||
}
|
||
} footer: {
|
||
Text("System follows this device’s appearance setting.")
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
}
|
||
.navigationTitle("Appearance")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
|
||
private func select(_ preference: AppAppearancePreference) {
|
||
guard preference != self.appearanceModel.preference else { return }
|
||
self.dismiss()
|
||
Task { @MainActor in
|
||
// Changing the root scheme while an iPad split-view destination is active can
|
||
// leave that destination blank. Apply only after the native pop transition.
|
||
try? await Task.sleep(for: .milliseconds(500))
|
||
self.appearanceModel.select(preference)
|
||
}
|
||
}
|
||
}
|
||
|
||
extension SettingsProTab {
|
||
var appearanceRow: some View {
|
||
AppearanceSettingsRow()
|
||
}
|
||
|
||
var gatewaySection: some View {
|
||
Section("Gateway") {
|
||
HStack(spacing: 8) {
|
||
NavigationLink(value: SettingsRoute.gateway) {
|
||
self.gatewayConnectionRow
|
||
}
|
||
if self.gatewayRegistry.entries.count > 1 {
|
||
self.gatewayQuickSwitchMenu
|
||
}
|
||
}
|
||
SettingsDetailRow("Address", value: self.gatewayAddress)
|
||
SettingsDetailRow("Server", value: self.gatewayServer)
|
||
SettingsDetailRow("Agents", value: "\(self.appModel.gatewayAgents.count)")
|
||
self.gatewayActions
|
||
}
|
||
}
|
||
|
||
var gatewayConnectionRow: some View {
|
||
LabeledContent {
|
||
Text(self.gatewayStatusDetail)
|
||
.font(OpenClawType.subhead)
|
||
.foregroundStyle(self.gatewayStatusColor)
|
||
} label: {
|
||
Text("Connection")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder var settingsListSection: some View {
|
||
Section {
|
||
self.settingsListRow(
|
||
icon: "checkmark.shield.fill",
|
||
iconColor: self.pendingApproval == nil ? .green : .orange,
|
||
title: "Approvals",
|
||
route: .approvals,
|
||
badgeValue: self.pendingApproval == nil ? nil : "1")
|
||
self.settingsListRow(
|
||
icon: "person.2.fill",
|
||
iconColor: .blue,
|
||
title: "Permissions",
|
||
route: .permissions)
|
||
self.settingsListRow(
|
||
icon: "point.3.connected.trianglepath.dotted",
|
||
iconColor: .purple,
|
||
title: "Channels",
|
||
route: .channels)
|
||
self.settingsListRow(
|
||
icon: "waveform",
|
||
iconColor: .pink,
|
||
title: "Voice & Talk",
|
||
route: .voice)
|
||
}
|
||
|
||
Section {
|
||
self.appearanceRow
|
||
self.settingsListRow(
|
||
icon: "stethoscope",
|
||
iconColor: .teal,
|
||
title: "Diagnostics",
|
||
route: .diagnostics)
|
||
self.settingsListRow(
|
||
icon: "hand.raised.fill",
|
||
iconColor: .indigo,
|
||
title: "Privacy",
|
||
route: .privacy)
|
||
self.settingsListRow(
|
||
icon: "applewatch",
|
||
iconColor: .green,
|
||
title: "Apple Watch",
|
||
route: .appleWatch)
|
||
self.settingsListRow(
|
||
icon: "info.circle.fill",
|
||
iconColor: .gray,
|
||
title: "About",
|
||
route: .about)
|
||
} header: {
|
||
Text("Device")
|
||
.font(OpenClawType.captionSemiBold)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
Section {
|
||
self.settingsListRow(
|
||
icon: "doc.text",
|
||
iconColor: .gray,
|
||
title: "Licenses",
|
||
route: .licenses)
|
||
.accessibilityIdentifier("settings-licenses-row")
|
||
}
|
||
}
|
||
|
||
func settingsListRow(
|
||
icon: String,
|
||
iconColor: Color,
|
||
title: String,
|
||
route: SettingsRoute,
|
||
badgeValue: String? = nil) -> some View
|
||
{
|
||
NavigationLink(value: route) {
|
||
Label {
|
||
Text(title)
|
||
.font(OpenClawType.subheadSemiBold)
|
||
} icon: {
|
||
SettingsIcon(systemName: icon, color: iconColor)
|
||
}
|
||
}
|
||
.badge(badgeValue.map { Text($0).font(OpenClawType.captionSemiBold) })
|
||
}
|
||
|
||
@ViewBuilder
|
||
func destination(for route: SettingsRoute) -> some View {
|
||
switch route {
|
||
case .channels:
|
||
SettingsChannelsDestination()
|
||
.navigationTitle(title(for: route))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
default:
|
||
List {
|
||
switch route {
|
||
case .gateway:
|
||
self.gatewayDestination
|
||
case .appleWatch:
|
||
self.appleWatchDestination
|
||
case .approvals:
|
||
self.approvalsDestination
|
||
case .permissions:
|
||
self.permissionsDestination
|
||
case .voice:
|
||
self.voiceDestination
|
||
case .diagnostics:
|
||
self.diagnosticsDestination
|
||
case .privacy:
|
||
self.privacyDestination
|
||
case .notifications:
|
||
self.notificationsDestination
|
||
case .about:
|
||
self.aboutDestination
|
||
case .licenses:
|
||
self.licensesDestination
|
||
case .channels:
|
||
EmptyView()
|
||
}
|
||
}
|
||
.font(OpenClawType.body)
|
||
.navigationTitle(title(for: route))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.task(id: route) {
|
||
guard route == .appleWatch else { return }
|
||
await self.appModel.refreshWatchMessagingStatus()
|
||
}
|
||
.toolbar {
|
||
if let headerLeadingAction {
|
||
ToolbarItem(placement: .topBarLeading) {
|
||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||
}
|
||
}
|
||
ToolbarItem(placement: .principal) {
|
||
Text(title(for: route))
|
||
.font(OpenClawType.headline)
|
||
.foregroundStyle(.primary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var gatewayDestination: some View {
|
||
Group {
|
||
self.detailStatusCard(
|
||
icon: "antenna.radiowaves.left.and.right",
|
||
title: "Gateway",
|
||
detail: self.gatewayStatusDetail,
|
||
value: self.gatewayStatusValue,
|
||
color: self.gatewayStatusColor)
|
||
|
||
self.detailListCard {
|
||
SettingsDetailRow("Address", value: self.gatewayAddress)
|
||
SettingsDetailRow("Server", value: self.gatewayServer)
|
||
SettingsDetailRow("Discovered", value: "\(self.gatewayController.gateways.count)")
|
||
SettingsDetailRow("Default Agent", value: self.appModel.activeAgentName)
|
||
SettingsDetailRow("Agents", value: "\(self.appModel.gatewayAgents.count)")
|
||
}
|
||
|
||
Section {
|
||
Button {
|
||
Task { await self.reconnectGateway() }
|
||
} label: {
|
||
Label("Reconnect", systemImage: "arrow.triangle.2.circlepath")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(self.isReconnectingGateway || self.appModel.isAppleReviewDemoModeEnabled)
|
||
Button {
|
||
Task { await self.runDiagnostics() }
|
||
} label: {
|
||
Label("Diagnose", systemImage: "cross.case")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(self.isRefreshingGateway)
|
||
}
|
||
|
||
self.manualGatewayCard
|
||
self.pairedGatewaysCard
|
||
self.deviceIdentityCard
|
||
self.agentSelectionCard
|
||
self.gatewaySetupCard
|
||
self.discoveredGatewaysCard
|
||
self.gatewayAdvancedCard
|
||
}
|
||
.font(OpenClawType.body)
|
||
}
|
||
|
||
var gatewayQuickSwitchMenu: some View {
|
||
Menu {
|
||
ForEach(self.gatewayRegistry.entries) { entry in
|
||
Button {
|
||
Task { await self.switchGateway(to: entry) }
|
||
} label: {
|
||
Label {
|
||
Text(entry.name)
|
||
.font(OpenClawType.body)
|
||
} icon: {
|
||
Image(systemName: GatewayStableIdentifier.matches(
|
||
entry.stableID,
|
||
self.gatewayRegistry.activeStableID)
|
||
? "checkmark.circle.fill"
|
||
: "circle")
|
||
}
|
||
}
|
||
.disabled(
|
||
GatewayStableIdentifier.matches(entry.stableID, self.gatewayRegistry.activeStableID) ||
|
||
self.connectingGateway != nil)
|
||
}
|
||
} label: {
|
||
Image(systemName: "arrow.triangle.2.circlepath")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
.foregroundStyle(OpenClawBrand.accent)
|
||
}
|
||
.accessibilityLabel("Switch Gateway")
|
||
}
|
||
|
||
var approvalsDestination: some View {
|
||
Group {
|
||
self.detailStatusCard(
|
||
icon: "checkmark.shield.fill",
|
||
title: "Approvals",
|
||
detail: self.notificationsNeedAttention
|
||
? "Out-of-app approval alerts need notification permission."
|
||
: (self.pendingApprovalCount == 0 ? "No gateway actions are waiting for review." :
|
||
"Review pending gateway actions."),
|
||
value: self.notificationsNeedAttention
|
||
? "Alerts Off"
|
||
: (self.pendingApprovalCount == 0 ? "clear" : self.approvalWaitingText),
|
||
color: self.notificationsNeedAttention ? OpenClawBrand.warn :
|
||
(self.pendingApprovalCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn))
|
||
|
||
if self.notificationsNeedAttention {
|
||
self.approvalNotificationsWarningCard
|
||
}
|
||
|
||
self.approvalsReviewCard
|
||
}
|
||
}
|
||
|
||
var appleWatchDestination: some View {
|
||
Group {
|
||
let watchStatus = self.appModel.watchMessagingStatus
|
||
self.detailStatusCard(
|
||
icon: "applewatch",
|
||
title: "Apple Watch",
|
||
detail: watchStatus.appInstalled
|
||
? "Relay remains available; direct mode adds an independent Gateway node."
|
||
: "Install the OpenClaw watch app before enabling direct mode.",
|
||
value: watchStatus.reachable ? "Reachable" : (watchStatus.appInstalled ? "Installed" : "Unavailable"),
|
||
color: watchStatus.appInstalled ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||
|
||
Section {
|
||
Button {
|
||
Task { await self.sendDirectWatchSetup() }
|
||
} label: {
|
||
Label("Enable Direct Gateway Connection", systemImage: "point.3.connected.trianglepath.dotted")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(
|
||
self.isSendingWatchDirectSetup
|
||
|| !self.appModel.isOperatorGatewayConnected
|
||
|| !self.appModel.hasOperatorAdminScope
|
||
|| !watchStatus.appInstalled)
|
||
|
||
if let statusText = self.watchDirectSetupStatusText {
|
||
Text(statusText)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
} footer: {
|
||
Text(
|
||
"""
|
||
The watch receives a one-time pairing code and stores its own device token. \
|
||
A reachable secure Gateway URL is required away from the iPhone.
|
||
""")
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
|
||
Section("Direct node features") {
|
||
SettingsDetailRow("Device", value: "Info and status")
|
||
SettingsDetailRow("Notifications", value: "While app is active")
|
||
}
|
||
}
|
||
}
|
||
|
||
var approvalNotificationsWarningCard: some View {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Notifications are off")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
Text("Enable Notifications to receive approval alerts while OpenClaw is not open.")
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
if self.directRoute == nil {
|
||
Button {
|
||
self.openNotificationsRouteFromApprovals()
|
||
} label: {
|
||
Label("Open Notifications", systemImage: "bell.badge")
|
||
.font(OpenClawType.body)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
var approvalsReviewCard: some View {
|
||
if !self.appModel.pendingExecApprovalInboxItems.isEmpty {
|
||
Section("Pending approvals") {
|
||
ForEach(self.appModel.pendingExecApprovalInboxItems) { item in
|
||
Button {
|
||
self.appModel.presentPendingExecApprovalFromInbox(item.id)
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(item.prompt.commandPreview ?? item.prompt.commandText)
|
||
.font(OpenClawType.body)
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(2)
|
||
Text(item.prompt.gatewayStableID)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.accessibilityLabel("Review exec approval")
|
||
.accessibilityValue(item.prompt.commandPreview ?? item.prompt.commandText)
|
||
}
|
||
}
|
||
}
|
||
|
||
if let pendingApproval {
|
||
Section("Reviewing") {
|
||
ForEach(self.approvalItems, id: \.id) { item in
|
||
SettingsApprovalRow(item: item)
|
||
}
|
||
if let warningText = pendingApproval.warningText {
|
||
Label {
|
||
Text(warningText)
|
||
.font(OpenClawType.caption)
|
||
} icon: {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
}
|
||
.foregroundStyle(OpenClawBrand.warn)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
if let errorText = self.appModel.pendingExecApprovalPromptErrorText {
|
||
Text(errorText)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(OpenClawBrand.danger)
|
||
}
|
||
if let resolvedText = self.appModel.pendingExecApprovalPromptResolvedText {
|
||
Text(resolvedText)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(self.approvalOutcomeColor)
|
||
Button {
|
||
self.appModel.dismissPendingExecApprovalPrompt()
|
||
} label: {
|
||
Label("Dismiss", systemImage: "xmark")
|
||
.font(OpenClawType.body)
|
||
}
|
||
} else {
|
||
if pendingApproval.allowsAllowOnce {
|
||
Button {
|
||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
|
||
} label: {
|
||
Label("Allow Once", systemImage: "checkmark")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||
}
|
||
if pendingApproval.allowsAllowAlways {
|
||
Button {
|
||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always") }
|
||
} label: {
|
||
Label("Allow Always", systemImage: "checkmark.shield")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||
}
|
||
if pendingApproval.allowsDeny {
|
||
Button(role: .destructive) {
|
||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
|
||
} label: {
|
||
Label("Deny", systemImage: "xmark")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||
}
|
||
if self.appModel.pendingExecApprovalPromptResolving,
|
||
self.appModel.pendingExecApprovalPromptCanDismiss
|
||
{
|
||
Button(role: .cancel) {
|
||
self.appModel.dismissPendingExecApprovalPrompt()
|
||
} label: {
|
||
Label("Dismiss", systemImage: "xmark")
|
||
.font(OpenClawType.body)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if self.pendingApprovalCount == 0 {
|
||
Section {
|
||
Label {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("No approvals waiting")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
Text(self.approvalEmptyDetail)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
} icon: {
|
||
Image(systemName: "checkmark.shield.fill")
|
||
.foregroundStyle(OpenClawBrand.ok)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var approvalOutcomeColor: Color {
|
||
switch self.appModel.pendingExecApprovalPromptOutcome?.tone {
|
||
case .success:
|
||
OpenClawBrand.ok
|
||
case .danger:
|
||
OpenClawBrand.danger
|
||
case .warning:
|
||
OpenClawBrand.warn
|
||
case .neutral, nil:
|
||
.secondary
|
||
}
|
||
}
|
||
|
||
var permissionsDestination: some View {
|
||
Group {
|
||
self.toggleCard(
|
||
title: "Camera",
|
||
isOn: self.$cameraEnabled)
|
||
|
||
self.locationModeCard
|
||
|
||
self.toggleCard(
|
||
title: "Keep Awake",
|
||
isOn: self.$preventSleep)
|
||
|
||
self.privacyAccessCard
|
||
}
|
||
}
|
||
|
||
var voiceDestination: some View {
|
||
Group {
|
||
self.detailStatusCard(
|
||
icon: "waveform",
|
||
title: "Voice & Talk",
|
||
detail: self.appModel.talkMode.gatewayTalkVoiceModeTitle,
|
||
value: self.voiceDetail,
|
||
color: self.talkEnabled || self.voiceWakeEnabled ? OpenClawBrand.accent : .secondary)
|
||
|
||
self.voiceFeatureCard
|
||
self.talkVoiceSettingsCard
|
||
self.shareSettingsCard
|
||
}
|
||
}
|
||
|
||
var diagnosticsDestination: some View {
|
||
Group {
|
||
self.detailStatusCard(
|
||
icon: "checklist.checked",
|
||
title: "Health Check",
|
||
detail: "Run app, permission, and gateway-adjacent checks without editing setup.",
|
||
value: self.diagnosticsHealthValue,
|
||
color: self.gatewayDiagnosticConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||
|
||
Section {
|
||
Button {
|
||
Task { await self.runDiagnostics() }
|
||
} label: {
|
||
Label("Run Diagnostics", systemImage: "cross.case")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(self.isRefreshingGateway)
|
||
}
|
||
|
||
self.diagnosticChecksCard
|
||
|
||
self.detailListCard {
|
||
SettingsDetailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||
SettingsDetailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
|
||
SettingsDetailRow("App", value: DeviceInfoHelper.openClawVersionString())
|
||
SettingsDetailRow("Model", value: DeviceInfoHelper.modelIdentifier())
|
||
}
|
||
|
||
self.diagnosticsAdvancedCard
|
||
}
|
||
}
|
||
|
||
var privacyDestination: some View {
|
||
Group {
|
||
self.notificationsSection
|
||
|
||
self.toggleCard(
|
||
title: "Camera Access",
|
||
isOn: self.$cameraEnabled)
|
||
|
||
self.locationModeCard
|
||
|
||
self.toggleCard(
|
||
title: "Background Listening",
|
||
isOn: self.$talkBackgroundEnabled)
|
||
|
||
self.privacyAccessCard
|
||
}
|
||
}
|
||
|
||
var notificationsDestination: some View {
|
||
self.notificationsSection
|
||
}
|
||
|
||
var notificationsSection: some View {
|
||
Section("Notifications") {
|
||
HStack(spacing: 12) {
|
||
SettingsIcon(systemName: "bell.fill", color: self.notificationStatusColor)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Notifications")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
Text(self.notificationStatusDetail)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
Spacer(minLength: 8)
|
||
Toggle(isOn: self.notificationToggleBinding) {
|
||
Text("Notifications")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
}
|
||
.labelsHidden()
|
||
.disabled(self.notificationStatus == .checking || self.isRequestingNotificationAuthorization)
|
||
.accessibilityIdentifier("settings-notifications-toggle")
|
||
.accessibilityValue(self.notificationServingActive ? "On" : "Off")
|
||
.accessibilityHint("Turns OpenClaw notification delivery on or off")
|
||
}
|
||
|
||
HStack(alignment: .top, spacing: 10) {
|
||
Image(systemName: "network")
|
||
.font(OpenClawType.captionSemiBold)
|
||
.foregroundStyle(OpenClawBrand.accent)
|
||
.frame(width: 22, height: 22)
|
||
Text(self.notificationRelayDetail)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
.accessibilityIdentifier("settings-privacy-notifications-section")
|
||
}
|
||
|
||
var gatewayActions: some View {
|
||
Group {
|
||
self.gatewayActionButton(
|
||
title: "Reconnect",
|
||
icon: "arrow.triangle.2.circlepath",
|
||
color: OpenClawBrand.accent,
|
||
isBusy: self.isReconnectingGateway,
|
||
isDisabled: self.appModel.isAppleReviewDemoModeEnabled)
|
||
{
|
||
Task { await self.reconnectGateway() }
|
||
}
|
||
|
||
self.gatewayActionButton(
|
||
title: "Diagnose",
|
||
icon: "cross.case",
|
||
color: OpenClawBrand.accent,
|
||
isBusy: self.isRefreshingGateway)
|
||
{
|
||
Task { await self.runDiagnostics() }
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder var licensesDestination: some View {
|
||
let documents = LicenseDocumentLoader.bundledDocuments()
|
||
if documents.isEmpty {
|
||
ContentUnavailableView(
|
||
"No Licenses Bundled",
|
||
systemImage: "doc.text",
|
||
description: Text("License files are not available in this build."))
|
||
.font(OpenClawType.body)
|
||
} else {
|
||
Section {
|
||
ForEach(documents) { document in
|
||
NavigationLink {
|
||
LicenseDocumentDetailView(document: document)
|
||
} label: {
|
||
Label {
|
||
Text(document.title)
|
||
.font(OpenClawType.subhead)
|
||
} icon: {
|
||
SettingsIcon(systemName: "doc.text", color: .gray)
|
||
}
|
||
}
|
||
}
|
||
} footer: {
|
||
Text("OpenClaw appreciates its partners in the open-source community.")
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
.accessibilityIdentifier("settings-licenses-list")
|
||
}
|
||
}
|
||
|
||
/// Native inset-grouped action row (plain tinted text, no pill chrome).
|
||
func gatewayActionButton(
|
||
title: String,
|
||
icon: String,
|
||
color: Color,
|
||
isBusy: Bool,
|
||
isDisabled: Bool = false,
|
||
action: @escaping () -> Void) -> some View
|
||
{
|
||
Button(action: action) {
|
||
HStack {
|
||
Label(title, systemImage: icon)
|
||
.font(OpenClawType.body)
|
||
Spacer()
|
||
if isBusy {
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(color)
|
||
.disabled(isBusy || isDisabled)
|
||
.accessibilityLabel(title)
|
||
}
|
||
|
||
var aboutDestination: some View {
|
||
Group {
|
||
Section {
|
||
VStack(spacing: 12) {
|
||
OpenClawProMark(size: 96, shadowRadius: 18, interactive: true)
|
||
.accessibilityHidden(true)
|
||
VStack(spacing: 2) {
|
||
Text("OpenClaw")
|
||
.font(OpenClawType.title2SemiBold)
|
||
Text("Personal AI on your devices")
|
||
.font(OpenClawType.footnote)
|
||
.foregroundStyle(.secondary)
|
||
SettingsBuildMetadataStrip(metadata: DeviceInfoHelper.buildMetadata())
|
||
.padding(.top, 8)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.top, 4)
|
||
.accessibilityElement(children: .contain)
|
||
.listRowBackground(Color.clear)
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
|
||
// Concise public details only; deep hardware identifiers live in Diagnostics.
|
||
detailListCard {
|
||
SettingsDetailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||
SettingsDetailRow("iOS", value: DeviceInfoHelper.iOSVersionStringForDisplay())
|
||
}
|
||
|
||
Section {
|
||
self.aboutLinkRow(
|
||
title: "Website",
|
||
icon: "globe",
|
||
color: .blue,
|
||
url: URL(string: "https://openclaw.ai")!)
|
||
self.aboutLinkRow(
|
||
title: "Docs",
|
||
icon: "book.fill",
|
||
color: .orange,
|
||
url: URL(string: "https://docs.openclaw.ai")!)
|
||
self.aboutLinkRow(
|
||
title: "GitHub",
|
||
icon: "chevron.left.slash.chevron.right",
|
||
color: .gray,
|
||
url: URL(string: "https://github.com/openclaw/openclaw")!)
|
||
self.aboutLinkRow(
|
||
title: "Discord",
|
||
icon: "bubble.left.and.bubble.right.fill",
|
||
color: .indigo,
|
||
url: URL(string: "https://discord.gg/clawd")!)
|
||
} footer: {
|
||
Text("© 2026 OpenClaw Foundation — MIT License.")
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// About link row with explicit branded label; shorthand `Link("Title", ...)`
|
||
/// would bypass the typography audit and OpenClawType styling.
|
||
func aboutLinkRow(title: String, icon: String, color: Color, url: URL) -> some View {
|
||
Link(destination: url) {
|
||
HStack {
|
||
Label {
|
||
Text(title)
|
||
.font(OpenClawType.subheadSemiBold)
|
||
.foregroundStyle(.primary)
|
||
} icon: {
|
||
SettingsIcon(systemName: icon, color: color)
|
||
}
|
||
Spacer()
|
||
Image(systemName: "arrow.up.right")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.accessibilityLabel(title)
|
||
}
|
||
|
||
func toggleCard(title: String, isOn: Binding<Bool>) -> some View {
|
||
Section {
|
||
self.settingsToggle(title, isOn: isOn)
|
||
}
|
||
}
|
||
|
||
var locationModeCard: some View {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Button {
|
||
self.handleLocationSharingTap()
|
||
} label: {
|
||
HStack {
|
||
Text("Location")
|
||
.font(OpenClawType.body)
|
||
.foregroundStyle(.primary)
|
||
Spacer(minLength: 8)
|
||
ZStack {
|
||
OpenClawToggleIndicator(isOn: self.locationSettingsPresentation.sharingControlIsOn)
|
||
.opacity(self.isChangingLocationMode ? 0 : 1)
|
||
if self.isChangingLocationMode {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(self.isChangingLocationMode)
|
||
.accessibilityIdentifier("settings-location-sharing-toggle")
|
||
.accessibilityLabel("Location Sharing")
|
||
.accessibilityValue(self.locationSettingsPresentation.sharingControlIsOn ? "On" : "Off")
|
||
|
||
if self.locationSettingsPresentation.showsAccessLevel,
|
||
let accessLevelText = self.locationSettingsPresentation.accessLevelText
|
||
{
|
||
Divider()
|
||
Button {
|
||
self.showLocationAccessDialog = true
|
||
} label: {
|
||
HStack(alignment: .firstTextBaseline) {
|
||
Text("Access Level")
|
||
.font(OpenClawType.body)
|
||
.foregroundStyle(.primary)
|
||
Spacer(minLength: 8)
|
||
Text(accessLevelText)
|
||
.font(OpenClawType.subhead)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.trailing)
|
||
.lineLimit(2)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
Image(systemName: "chevron.up.chevron.down")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(self.isChangingLocationMode)
|
||
.accessibilityElement(children: .ignore)
|
||
.accessibilityIdentifier("settings-location-access-level")
|
||
.accessibilityLabel("Access Level")
|
||
.accessibilityValue(accessLevelText)
|
||
.accessibilityHint("Chooses While Using the App or Always")
|
||
}
|
||
|
||
if let locationPermissionDetailText {
|
||
Text(locationPermissionDetailText)
|
||
.font(OpenClawType.caption2)
|
||
.foregroundStyle(OpenClawBrand.warn)
|
||
}
|
||
|
||
if let locationPermissionWarningText {
|
||
Text(locationPermissionWarningText)
|
||
.font(OpenClawType.caption2)
|
||
.foregroundStyle(OpenClawBrand.warn)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var agentSelectionCard: some View {
|
||
Section {
|
||
Picker("Default Agent", selection: self.$selectedAgentPickerId) {
|
||
Text("Default").font(OpenClawType.body).tag("")
|
||
let defaultId = (self.appModel.gatewayDefaultAgentId ?? "")
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in
|
||
let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||
Text(name.isEmpty ? agent.id : name).font(OpenClawType.body).tag(agent.id)
|
||
}
|
||
}
|
||
.font(OpenClawType.body)
|
||
} footer: {
|
||
Text("Used for new Chat and Talk sessions.")
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
}
|
||
|
||
var gatewaySetupCard: some View {
|
||
Section {
|
||
TextField("Paste setup code", text: self.$setupCode)
|
||
.font(OpenClawType.body)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.disabled(self.connectingGateway != nil)
|
||
self.gatewayActionButton(
|
||
title: "Scan QR",
|
||
icon: "qrcode.viewfinder",
|
||
color: OpenClawBrand.accent,
|
||
isBusy: false,
|
||
isDisabled: self.connectingGateway != nil)
|
||
{
|
||
self.openGatewayQRScanner()
|
||
}
|
||
|
||
self.gatewayActionButton(
|
||
title: "Connect",
|
||
icon: "bolt.horizontal.circle",
|
||
color: OpenClawBrand.accent,
|
||
isBusy: self.connectingGateway == .manual,
|
||
isDisabled: !self.canApplyGatewaySetup || self.connectingGateway != nil)
|
||
{
|
||
Task { await self.applySetupCodeAndConnect() }
|
||
}
|
||
} header: {
|
||
Text("Setup Code")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
} footer: {
|
||
if let warning = self.tailnetWarningText {
|
||
Text(warning).font(OpenClawType.footnote).foregroundStyle(OpenClawBrand.warn)
|
||
} else if let status = self.setupStatusLine {
|
||
Text(status)
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
}
|
||
}
|
||
|
||
var discoveredGatewaysCard: some View {
|
||
Section("Discovered Gateways") {
|
||
if self.gatewayController.gateways.isEmpty {
|
||
Text("No gateways found yet. Use manual setup if Bonjour is blocked.")
|
||
.font(OpenClawType.subhead)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(self.gatewayController.gateways) { gateway in
|
||
self.discoveredGatewayRow(gateway)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var pairedGatewaysCard: some View {
|
||
Section {
|
||
if self.gatewayRegistry.entries.isEmpty {
|
||
Text("Pair a gateway to make it available here.")
|
||
.font(OpenClawType.subhead)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(self.gatewayRegistry.entries) { entry in
|
||
self.pairedGatewayRow(entry)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Paired Gateways")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
} footer: {
|
||
Text("Switch gateways without pairing again.")
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
}
|
||
|
||
func pairedGatewayRow(_ entry: GatewaySettingsStore.GatewayRegistryEntry) -> some View {
|
||
let isActive = GatewayStableIdentifier.matches(
|
||
entry.stableID,
|
||
self.gatewayRegistry.activeStableID)
|
||
return Button {
|
||
guard !isActive else { return }
|
||
Task { await self.switchGateway(to: entry) }
|
||
} label: {
|
||
HStack(spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(entry.name)
|
||
.font(OpenClawType.subheadSemiBold)
|
||
.foregroundStyle(.primary)
|
||
Text(self.gatewayEndpointSummary(entry))
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer(minLength: 8)
|
||
if self.connectingGateway == .gateway(entry.id) {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
} else if isActive {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
.foregroundStyle(OpenClawBrand.accent)
|
||
.accessibilityLabel("Active Gateway")
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(self.connectingGateway != nil)
|
||
.swipeActions {
|
||
Button(role: .destructive) {
|
||
self.pendingForgetGateway = entry
|
||
} label: {
|
||
Label {
|
||
Text("Forget")
|
||
.font(OpenClawType.captionSemiBold)
|
||
} icon: {
|
||
Image(systemName: "trash")
|
||
}
|
||
}
|
||
}
|
||
.contextMenu {
|
||
Button(role: .destructive) {
|
||
self.pendingForgetGateway = entry
|
||
} label: {
|
||
Label {
|
||
Text("Forget Gateway")
|
||
.font(OpenClawType.body)
|
||
} icon: {
|
||
Image(systemName: "trash")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func discoveredGatewayRow(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> some View {
|
||
let availability = self.gatewayController.discoveredGatewayConnectionAvailability(gateway)
|
||
return VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(verbatim: gateway.name)
|
||
.font(OpenClawType.subheadSemiBold)
|
||
Text(verbatim: self.gatewayDetailLines(gateway).joined(separator: " • "))
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(2)
|
||
}
|
||
Spacer(minLength: 8)
|
||
if availability.canConnect {
|
||
Button {
|
||
Task { await self.connect(gateway) }
|
||
} label: {
|
||
if self.connectingGateway == .gateway(gateway.id) {
|
||
ProgressView().controlSize(.small)
|
||
} else {
|
||
Text(availability.actionTitle)
|
||
.font(OpenClawType.captionSemiBold)
|
||
}
|
||
}
|
||
.font(OpenClawType.captionSemiBold)
|
||
.buttonStyle(.bordered)
|
||
.disabled(self.connectingGateway != nil)
|
||
} else {
|
||
Text(availability.actionTitle)
|
||
.font(OpenClawType.captionSemiBold)
|
||
.foregroundStyle(OpenClawBrand.warn)
|
||
}
|
||
}
|
||
|
||
if let guidanceText = availability.guidanceText {
|
||
Text(guidanceText)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
var manualGatewayCard: some View {
|
||
Section("Manual Gateway") {
|
||
self.settingsToggle("Use Manual Gateway", isOn: self.manualGatewayEnabledBinding)
|
||
TextField("Host", text: self.manualHostBinding)
|
||
.font(OpenClawType.body)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
TextField("Port", text: self.manualPortBinding)
|
||
.font(OpenClawType.body)
|
||
.keyboardType(.numberPad)
|
||
Picker(selection: self.manualGatewayTLSBinding) {
|
||
Text("Unencrypted")
|
||
.font(OpenClawType.captionSemiBold)
|
||
.tag(false)
|
||
Text("Secure (TLS)")
|
||
.font(OpenClawType.captionSemiBold)
|
||
.tag(true)
|
||
} label: {
|
||
Text("Connection security")
|
||
.font(OpenClawType.captionSemiBold)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.disabled(self.manualGatewayTransport.requiresTLS)
|
||
if let helperText = self.manualGatewayTransport.helperText {
|
||
Text(helperText)
|
||
.font(OpenClawType.footnote)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
self.gatewayActionButton(
|
||
title: "Connect Manual",
|
||
icon: "network",
|
||
color: OpenClawBrand.accent,
|
||
isBusy: self.connectingGateway == .manual,
|
||
isDisabled: self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
|| !self.manualPortIsValid)
|
||
{
|
||
Task { await self.connectManual() }
|
||
}
|
||
}
|
||
.disabled(self.setupAttemptID != nil)
|
||
}
|
||
|
||
private var manualGatewayTransport: GatewayManualTransportPresentation {
|
||
GatewayConnectionController.manualTransportPresentation(
|
||
host: self.manualGatewayHost,
|
||
requestedTLS: self.manualGatewayTLS)
|
||
}
|
||
|
||
private var manualGatewayTLSBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { self.manualGatewayTransport.effectiveTLS },
|
||
set: { enabled in
|
||
guard !self.manualGatewayTransport.requiresTLS else { return }
|
||
self.manualGatewayTLS = enabled
|
||
})
|
||
}
|
||
|
||
var gatewayAdvancedCard: some View {
|
||
Section {
|
||
self.settingsToggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
|
||
self.gatewaySecureField("Gateway Auth Token", text: self.gatewayTokenBinding)
|
||
self.gatewaySecureField("Gateway Password", text: self.gatewayPasswordBinding)
|
||
if let headersStableID = self.gatewayCustomHeadersTargetStableID {
|
||
NavigationLink {
|
||
GatewayCustomHeadersSettingsView(gatewayStableID: headersStableID)
|
||
} label: {
|
||
Text("Custom Headers")
|
||
.font(OpenClawType.body)
|
||
}
|
||
}
|
||
Button(role: .destructive) {
|
||
self.showResetOnboardingAlert = true
|
||
} label: {
|
||
Label("Reset Onboarding", systemImage: "arrow.counterclockwise")
|
||
.font(OpenClawType.body)
|
||
}
|
||
}
|
||
}
|
||
|
||
func gatewaySecureField(_ placeholder: String, text: Binding<String>) -> some View {
|
||
ZStack(alignment: .leading) {
|
||
SecureField("", text: text)
|
||
.font(OpenClawType.subhead)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.accessibilityLabel(placeholder)
|
||
if text.wrappedValue.isEmpty {
|
||
Text(placeholder)
|
||
.font(OpenClawType.subheadSemiBold)
|
||
.foregroundStyle(.tertiary)
|
||
.padding(.horizontal, 8)
|
||
.allowsHitTesting(false)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
.font(OpenClawType.subhead)
|
||
}
|
||
|
||
var voiceFeatureCard: some View {
|
||
Section {
|
||
self.settingsToggle("Voice Wake", isOn: self.$voiceWakeEnabled) { enabled in
|
||
self.appModel.setVoiceWakeEnabled(enabled)
|
||
}
|
||
self.settingsToggle("Talk Mode", isOn: self.$talkEnabled) { enabled in
|
||
guard !self.appModel.isAppleReviewDemoModeEnabled else {
|
||
self.talkEnabled = false
|
||
return
|
||
}
|
||
self.appModel.setTalkEnabled(enabled)
|
||
}
|
||
.disabled(self.appModel.isAppleReviewDemoModeEnabled)
|
||
Picker("Speech Language", selection: self.$talkSpeechLocale) {
|
||
ForEach(TalkSpeechLocale.supportedOptions()) { option in
|
||
Text(option.label).font(OpenClawType.body).tag(option.id)
|
||
}
|
||
}
|
||
.font(OpenClawType.body)
|
||
self.settingsToggle("Background Listening", isOn: self.$talkBackgroundEnabled)
|
||
self.settingsToggle("Speakerphone", isOn: self.talkSpeakerphoneBinding)
|
||
NavigationLink {
|
||
VoiceWakeWordsSettingsView()
|
||
} label: {
|
||
SettingsDetailRow(
|
||
"Wake Words",
|
||
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
|
||
}
|
||
}
|
||
}
|
||
|
||
var talkVoiceSettingsCard: some View {
|
||
Group {
|
||
if self.gatewayConnected,
|
||
let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue
|
||
{
|
||
Section {
|
||
TalkRuntimeIssueBanner(
|
||
issue: issue,
|
||
onOpenSettings: nil,
|
||
onShowDetails: {
|
||
self.showTalkIssueDetails = true
|
||
})
|
||
}
|
||
}
|
||
Section("Voice") {
|
||
Picker("Provider", selection: self.talkProviderSelectionBinding) {
|
||
ForEach(TalkModeProviderSelection.allCases) { option in
|
||
Text(option.label).font(OpenClawType.body).tag(option.rawValue)
|
||
}
|
||
}
|
||
.font(OpenClawType.body)
|
||
if self.shouldShowRealtimeVoicePicker {
|
||
Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) {
|
||
Text("Gateway Default").font(OpenClawType.body).tag("")
|
||
ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in
|
||
Text(TalkModeRealtimeVoiceSelection.label(for: voice)).font(OpenClawType.body).tag(voice)
|
||
}
|
||
}
|
||
.font(OpenClawType.body)
|
||
}
|
||
SettingsDetailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||
SettingsDetailRow("Active Voice", value: self.gatewayTalkActiveVoiceDetail)
|
||
if let issue = self.gatewayTalkLastIssueDetail {
|
||
SettingsDetailRow("Last Voice Issue", value: issue)
|
||
}
|
||
SettingsDetailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel)
|
||
SettingsDetailRow("API Key", value: self.talkApiKeyStatus)
|
||
}
|
||
}
|
||
}
|
||
|
||
var shareSettingsCard: some View {
|
||
Section {
|
||
self.settingsToggle("Show Talk Control", isOn: self.$talkButtonEnabled)
|
||
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
|
||
.font(OpenClawType.body)
|
||
.lineLimit(2...5)
|
||
.textInputAutocapitalization(.sentences)
|
||
Button {
|
||
Task { await self.appModel.runSharePipelineSelfTest() }
|
||
} label: {
|
||
Label("Run Share Self-Test", systemImage: "checkmark.seal")
|
||
.font(OpenClawType.body)
|
||
}
|
||
} footer: {
|
||
Text(self.appModel.lastShareEventText)
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
}
|
||
|
||
var privacyAccessCard: some View {
|
||
Section {
|
||
PrivacyAccessSectionView()
|
||
}
|
||
}
|
||
|
||
var diagnosticsAdvancedCard: some View {
|
||
Section {
|
||
self.settingsToggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) { enabled in
|
||
self.gatewayController.setDiscoveryDebugLoggingEnabled(enabled)
|
||
}
|
||
self.settingsToggle("Debug Screen Status", isOn: self.$canvasDebugStatusEnabled)
|
||
NavigationLink {
|
||
GatewayDiscoveryDebugLogView()
|
||
} label: {
|
||
SettingsDetailRow("Discovery Logs", value: self.gatewayController.discoveryStatusText)
|
||
}
|
||
}
|
||
}
|
||
|
||
var deviceIdentityCard: some View {
|
||
Section("Device") {
|
||
TextField("Device Name", text: self.$displayName)
|
||
.font(OpenClawType.body)
|
||
SettingsDetailRow("Instance ID", value: self.instanceId)
|
||
}
|
||
}
|
||
|
||
func settingsToggle(
|
||
_ title: String,
|
||
isOn: Binding<Bool>,
|
||
onChange: ((Bool) -> Void)? = nil) -> some View
|
||
{
|
||
// Native Toggle rows can ignore visible-row taps on iOS 26; reuse the shared indicator row.
|
||
Button {
|
||
isOn.wrappedValue.toggle()
|
||
} label: {
|
||
HStack {
|
||
Text(title)
|
||
.font(OpenClawType.body)
|
||
Spacer(minLength: 8)
|
||
OpenClawToggleIndicator(isOn: isOn.wrappedValue)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(title)
|
||
.accessibilityValue(isOn.wrappedValue ? "On" : "Off")
|
||
.onChange(of: isOn.wrappedValue) { _, enabled in
|
||
onChange?(enabled)
|
||
}
|
||
}
|
||
}
|