improve(ios): simplify Talk controls and composer alignment (#98736)

* refactor(ios): streamline talk and composer controls

* test(ios): restore Talk control state

* chore(ios): refresh native i18n inventory
This commit is contained in:
Peter Steinberger
2026-07-02 00:48:37 +01:00
committed by GitHub
parent 17482a4026
commit 3e50f41dd6
9 changed files with 256 additions and 228 deletions
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -78,8 +78,8 @@ Expected result: the assistant responds by voice. Tap `Stop Talk` when done.
## Talk + Background Audio
1. Tap the `Talk` tab.
2. Confirm `Speakerphone` is on.
3. Confirm `Background listening` is on.
2. Confirm the speaker button is highlighted.
3. Confirm the background-listening button is highlighted.
4. Tap `Start Talk`.
5. If iOS asks for microphone access, tap `Allow`.
6. If iOS asks for Speech Recognition access, tap `Allow`.
@@ -29,14 +29,6 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
}
}
var detail: String {
switch self {
case .system: "Matches the system appearance."
case .light: "Always uses light appearance."
case .dark: "Always uses dark appearance."
}
}
var colorScheme: ColorScheme? {
switch self {
case .system: nil
@@ -89,10 +89,6 @@ struct SettingsProTab: View {
self.settingsContent))
}
var appearancePreference: AppAppearancePreference {
AppAppearancePreference(rawValue: self.appearancePreferenceRaw) ?? .system
}
@ViewBuilder
private var settingsContent: some View {
if let directRoute {
@@ -22,17 +22,13 @@ extension SettingsProTab {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Appearance", uppercase: false)
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Picker("Appearance", selection: self.$appearancePreferenceRaw) {
ForEach(AppAppearancePreference.allCases) { preference in
Text(preference.label).tag(preference.rawValue)
}
Picker("Appearance", selection: self.$appearancePreferenceRaw) {
ForEach(AppAppearancePreference.allCases) { preference in
Text(preference.label).tag(preference.rawValue)
}
.pickerStyle(.segmented)
Text(self.appearancePreference.detail)
.font(.caption)
.foregroundStyle(.secondary)
}
.pickerStyle(.segmented)
.accessibilityIdentifier("settings-appearance-picker")
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
+43 -36
View File
@@ -96,7 +96,7 @@ struct TalkProTab: View {
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
self.voiceHeroCard
self.controlsCard
self.controlBar
}
.padding(.top, 16)
.padding(.bottom, 18)
@@ -156,48 +156,55 @@ struct TalkProTab: View {
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var controlsCard: some View {
CommandPanel(padding: 0) {
VStack(spacing: 0) {
self.controlToggleRow("Speakerphone", isOn: self.talkSpeakerphoneBinding)
Divider().padding(.leading, 14)
self.controlToggleRow("Background listening", isOn: self.$talkBackgroundEnabled)
Divider().padding(.leading, 14)
private var controlBar: some View {
OpenClawGlassControlGroup {
HStack(spacing: 12) {
self.iconToggle(
title: "Speakerphone",
systemImage: self.talkSpeakerphoneEnabled ? "speaker.wave.2.fill" : "speaker.slash.fill",
isOn: self.talkSpeakerphoneBinding,
accessibilityIdentifier: "talk-speakerphone-control")
self.iconToggle(
title: "Background listening",
systemImage: self.talkBackgroundEnabled ? "waveform" : "waveform.slash",
isOn: self.$talkBackgroundEnabled,
accessibilityIdentifier: "talk-background-listening-control")
Button(action: self.openVoiceSettings) {
HStack {
Label("Voice & Talk settings", systemImage: "slider.horizontal.3")
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
}
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 14)
.padding(.vertical, 12)
Image(systemName: "slider.horizontal.3")
.font(.system(size: 17, weight: .semibold))
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.buttonBorderShape(.circle)
.openClawGlassButton()
.accessibilityLabel("Voice & Talk settings")
.accessibilityIdentifier("talk-voice-settings-control")
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func controlToggleRow(_ title: String, isOn: Binding<Bool>) -> some View {
Toggle(title, isOn: isOn)
.contentShape(Rectangle())
.padding(.horizontal, 14)
.padding(.vertical, 10)
.overlay {
// Keep Toggle semantics for accessibility while making the full visual row tappable.
Button {
isOn.wrappedValue.toggle()
} label: {
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHidden(true)
}
private func iconToggle(
title: String,
systemImage: String,
isOn: Binding<Bool>,
accessibilityIdentifier: String) -> some View
{
Button {
isOn.wrappedValue.toggle()
} label: {
Image(systemName: systemImage)
.font(.system(size: 17, weight: .semibold))
.contentTransition(.symbolEffect(.replace))
.frame(width: 44, height: 44)
}
.buttonBorderShape(.circle)
.openClawGlassButton(
prominent: isOn.wrappedValue,
tint: isOn.wrappedValue ? Color(uiColor: .systemBlue) : nil)
.accessibilityLabel(title)
.accessibilityValue(isOn.wrappedValue ? "On" : "Off")
.accessibilityIdentifier(accessibilityIdentifier)
}
private var gatewayConnected: Bool {
-6
View File
@@ -3,12 +3,6 @@ import UIKit
@testable import OpenClaw
struct OpenClawBrandTests {
@Test func `appearance preference details match selection`() {
#expect(AppAppearancePreference.system.detail == "Matches the system appearance.")
#expect(AppAppearancePreference.light.detail == "Always uses light appearance.")
#expect(AppAppearancePreference.dark.detail == "Always uses dark appearance.")
}
@Test func `semantic colors meet text contrast in both appearances`() {
let colors = [OpenClawBrand.uiOK, OpenClawBrand.uiWarn, OpenClawBrand.uiInfo]
let backgrounds = [UIColor.systemBackground, UIColor.secondarySystemBackground]
+49 -1
View File
@@ -67,10 +67,13 @@ final class OpenClawSnapshotUITests: XCTestCase {
initialDestination: "chat",
name: "chat-composer-growth"))
let textField = try XCTUnwrap(app?.textFields.firstMatch)
let textField = try XCTUnwrap(app?.textFields["chat-message-input"])
XCTAssertTrue(textField.waitForExistence(timeout: 8))
let talkButton = try XCTUnwrap(app?.buttons["chat-realtime-control"])
XCTAssertTrue(talkButton.waitForExistence(timeout: 5))
let compactHeight = textField.frame.height
XCTAssertLessThanOrEqual(compactHeight, 44)
XCTAssertLessThanOrEqual(abs(talkButton.frame.midY - textField.frame.midY), 1)
self.attachScreenshot(named: "chat-composer-compact")
textField.tap()
@@ -86,6 +89,51 @@ final class OpenClawSnapshotUITests: XCTestCase {
XCTAssertTrue(self.app?.keyboards.firstMatch.waitForNonExistence(timeout: 3) == true)
}
func testTalkUsesCompactIconControls() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone Talk controls only")
self.launchApp(for: ScreenshotTarget(
initialTab: "talk",
initialDestination: "talk",
name: "talk-icon-controls"))
let speakerphone = try XCTUnwrap(app?.buttons["talk-speakerphone-control"])
let backgroundListening = try XCTUnwrap(app?.buttons["talk-background-listening-control"])
let voiceSettings = try XCTUnwrap(app?.buttons["talk-voice-settings-control"])
XCTAssertTrue(speakerphone.waitForExistence(timeout: 8))
XCTAssertTrue(backgroundListening.exists)
XCTAssertTrue(voiceSettings.exists)
XCTAssertFalse(self.app?.switches["Speakerphone"].exists == true)
XCTAssertFalse(self.app?.switches["Background listening"].exists == true)
let originalValue = speakerphone.value as? String
defer {
if speakerphone.value as? String != originalValue {
speakerphone.tap()
}
}
if originalValue == "Off" {
speakerphone.tap()
}
XCTAssertEqual(speakerphone.value as? String, "On")
self.attachScreenshot(named: "talk-icon-controls")
let initialValue = speakerphone.value as? String
speakerphone.tap()
XCTAssertNotEqual(speakerphone.value as? String, initialValue)
}
func testAppearancePickerHasNoRedundantDescription() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone Settings proof only")
self.launchApp(for: ScreenshotTarget(
initialTab: "settings",
initialDestination: "settings",
name: "appearance-compact"))
XCTAssertTrue(self.app?.segmentedControls["settings-appearance-picker"].waitForExistence(timeout: 8) == true)
XCTAssertFalse(self.app?.staticTexts["Always uses light appearance."].exists == true)
self.attachScreenshot(named: "appearance-compact")
}
func testLiveGatewayControlOverviewNavigation() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone control hub only")
try XCTSkipUnless(
@@ -336,7 +336,7 @@ struct OpenClawChatComposer: View {
HStack(alignment: .bottom, spacing: 8) {
self.compactAccessory(self.attachmentPicker)
HStack(alignment: .bottom, spacing: 8) {
HStack(alignment: .center, spacing: 8) {
self.editorOverlay
.padding(.vertical, self.cleanEditorTextPadding)
.frame(minHeight: self.cleanEditorMinHeight)
@@ -396,6 +396,7 @@ struct OpenClawChatComposer: View {
.disabled(!talkControl.isGatewayConnected && !talkControl.isEnabled)
.accessibilityLabel(talkControl.isEnabled ? "Stop realtime chat" : "Start realtime chat")
.accessibilityValue(self.talkAccessibilityValue(talkControl))
.accessibilityIdentifier("chat-realtime-control")
.help(self.talkHelpText(talkControl))
}
@@ -420,6 +421,7 @@ struct OpenClawChatComposer: View {
.disabled(!talkControl.isGatewayConnected && !talkControl.isEnabled)
.accessibilityLabel(talkControl.isEnabled ? "Stop realtime chat" : "Start realtime chat")
.accessibilityValue(self.talkAccessibilityValue(talkControl))
.accessibilityIdentifier("chat-realtime-control")
.help(self.talkHelpText(talkControl))
}
@@ -528,6 +530,7 @@ struct OpenClawChatComposer: View {
.padding(.vertical, self.composerChrome == .clean ? 0 : 6)
.focused(self.$isFocused)
.disabled(!self.isComposerEnabled)
.accessibilityIdentifier("chat-message-input")
#endif
}
}