mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
+153
-161
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user