fix(ios): keep send available during voice input

This commit is contained in:
Kaede Akino committed 2026-09-27 19:25:37 +08:00
1 parent c1ae0dc0a2
commit c907cbd83e
2 files changed
+149 -25

No files matched your search

+42 -25
View File
@@ -207,7 +207,7 @@ struct MobileAssistantComposer: View {
ZStack {
if showsTranscriptionControls {
recordingControls
transcriptionControls(phase: model.speech.phase)
.transition(.opacity)
} else {
standardControls
@@ -257,10 +257,10 @@ struct MobileAssistantComposer: View {
}
@ViewBuilder
private var recordingControls: some View {
switch model.speech.phase {
func transcriptionControls(phase: SpeechTranscriptionCoordinator.Phase) -> some View {
switch phase {
case .recording:
speechControls {
speechControls(phase: phase) {
SpeechWaveformView(
samples: model.speech.levelSamples,
reduceMotion: reduceMotion
@@ -269,36 +269,47 @@ struct MobileAssistantComposer: View {
.accessibilityLabel(Text(AppStrings.Assistant.recording))
}
case .transcribing:
speechControls {
ProgressView()
speechControls(phase: phase) {
Text(AppStrings.Assistant.transcribing)
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.accessibilityLabel(Text(AppStrings.Assistant.transcribing))
}
case .idle:
EmptyView()
}
}
private func speechControls<Status: View>(@ViewBuilder status: () -> Status) -> some View {
private func speechControls<Status: View>(
phase: SpeechTranscriptionCoordinator.Phase, @ViewBuilder status: () -> Status
) -> some View {
HStack(alignment: .center, spacing: 8) {
Button {
composer.draft = speechDraft
model.speech.cancel(ownerID: conversation.id)
activeFocus.wrappedValue = true
} label: {
Label(AppStrings.Common.cancel.swiftUIKey, systemImage: "xmark").labelStyle(.iconOnly)
Label(AppStrings.Assistant.cancelTranscription.swiftUIKey, systemImage: "xmark").labelStyle(.iconOnly)
.mobileComposerControlIcon()
}
.buttonStyle(.plain)
status()
Button {
model.speech.stop(ownerID: conversation.id)
} label: {
Label(AppStrings.Common.done.swiftUIKey, systemImage: "stop.fill").labelStyle(.iconOnly)
.mobileComposerControlIcon()
if phase == .recording {
Button {
model.speech.stop(ownerID: conversation.id)
} label: {
Label(AppStrings.Assistant.stopRecording.swiftUIKey, systemImage: "stop.fill").labelStyle(.iconOnly)
.mobileComposerControlIcon()
}
.buttonStyle(.plain)
} else {
ProgressView()
.frame(width: 48, height: 48)
.accessibilityLabel(Text(AppStrings.Assistant.transcribing))
}
sendButton(enabled: phase == .recording) {
model.speech.stop(ownerID: conversation.id, sendAfterTranscription: true)
}
.buttonStyle(.plain)
.disabled(model.speech.phase != .recording)
}
.frame(minHeight: 48)
}
@@ -385,14 +396,18 @@ struct MobileAssistantComposer: View {
.frame(width: 48, height: 48).contentShape(.circle)
}
} else {
Button(action: send) {
Label(AppStrings.Assistant.send.swiftUIKey, systemImage: "arrow.up.circle.fill").labelStyle(.iconOnly)
.font(.system(size: 34, weight: .semibold)).frame(width: 48, height: 48).contentShape(.circle)
}
.disabled(!canSend)
sendButton(enabled: canSend, action: send)
}
}
private func sendButton(enabled: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Label(AppStrings.Assistant.send.swiftUIKey, systemImage: "arrow.up.circle.fill").labelStyle(.iconOnly)
.font(.system(size: 34, weight: .semibold)).frame(width: 48, height: 48).contentShape(.circle)
}
.disabled(!enabled)
}
private var contextDetailsSheet: some View {
NavigationStack {
Form {
@@ -672,13 +687,15 @@ struct MobileAssistantComposer: View {
speechDraft = composer.draft
activeFocus.wrappedValue = false
Task {
await model.speech.start(ownerID: conversation.id) { text, _ in
guard model.conversation.id == conversation.id else { return }
_ = conversation.applyTranscription(text, sendAfterTranscription: false, send: {})
}
await model.speech.start(ownerID: conversation.id, completion: applySpeechTranscript)
}
}
func applySpeechTranscript(_ text: String, sendAfterTranscription: Bool) {
guard model.conversation.id == conversation.id else { return }
_ = conversation.applyTranscription(text, sendAfterTranscription: sendAfterTranscription, send: send)
}
private func openCamera() {
Task {
let granted = await AVCaptureDevice.requestAccess(for: .video)
+107
View File
@@ -0,0 +1,107 @@
import Foundation
import SwiftUI
import TermindApplication
import Testing
import UIKit
@testable import TermindIOS
@Suite @MainActor
struct MobileAssistantSpeechTests {
@Test(arguments: [320.0, 768.0], ["en", "zh-Hans", "zh-Hant"])
func speechToolbarKeepsItsHeightOnPhoneAndPad(width: Double, language: String) throws {
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let composer = MobileAssistantComposer(pro: app.pro, model: assistant, conversation: assistant.conversation)
for phase in [SpeechTranscriptionCoordinator.Phase.recording, .transcribing] {
let controller = UIHostingController(
rootView: composer.transcriptionControls(phase: phase)
.fixedSize(horizontal: false, vertical: true)
.environment(\.locale, Locale(identifier: language))
.environment(\.dynamicTypeSize, .large)
)
let size = controller.sizeThatFits(in: CGSize(width: width, height: 200))
#expect(abs(size.height - 48) < 0.5)
#expect(size.width <= width)
}
}
@Test(arguments: [false, true])
func finalSpeechUsesTheComposerSendPathOnlyWhenRequested(shouldSend: Bool) async throws {
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let conversation = assistant.conversation
conversation.composer.draft = "Existing"
let composer = MobileAssistantComposer(pro: app.pro, model: assistant, conversation: conversation)
let service = MobileComposerSpeechService()
let speech = SpeechTranscriptionCoordinator(service: service)
await speech.start(ownerID: conversation.id, completion: composer.applySpeechTranscript)
speech.stop(ownerID: conversation.id, sendAfterTranscription: shouldSend)
#expect(speech.phase == .transcribing)
#expect(conversation.messages.isEmpty)
#expect(conversation.composer.draft == "Existing")
service.handler?(.final("Voice text"))
#expect(speech.phase == .idle)
if shouldSend {
#expect(conversation.composer.draft.isEmpty)
#expect(conversation.messages.map(\.text) == ["Existing Voice text"])
#expect(assistant.library.selectedConversation === conversation)
await conversation.stopAndWait()
} else {
#expect(conversation.composer.draft == "Existing Voice text")
#expect(conversation.messages.isEmpty)
}
}
@Test func speechSendExecutesComposerSlashCommands() throws {
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let conversation = assistant.conversation
let composer = MobileAssistantComposer(pro: app.pro, model: assistant, conversation: conversation)
composer.applySpeechTranscript("/plan", sendAfterTranscription: true)
#expect(conversation.isPlanning)
#expect(conversation.composer.draft.isEmpty)
#expect(conversation.messages.isEmpty)
}
@Test(arguments: ["cancel", "empty", "switch"])
func discardedSpeechDoesNotSendOrChangeDrafts(reason: String) async throws {
let app = try MobileAppModel(inMemory: true)
let assistant = app.assistant.makeSceneModel()
let conversation = assistant.conversation
conversation.composer.draft = "Original draft"
let composer = MobileAssistantComposer(pro: app.pro, model: assistant, conversation: conversation)
let service = MobileComposerSpeechService()
let speech = SpeechTranscriptionCoordinator(service: service)
await speech.start(ownerID: conversation.id, completion: composer.applySpeechTranscript)
speech.stop(ownerID: conversation.id, sendAfterTranscription: true)
if reason == "cancel" { speech.cancel(ownerID: conversation.id) }
if reason == "switch" { assistant.newConversation() }
service.handler?(.final(reason == "empty" ? " \n" : "Late speech"))
#expect(speech.phase == .idle)
#expect(conversation.composer.draft == "Original draft")
#expect(conversation.messages.isEmpty)
#expect(assistant.conversation.messages.isEmpty)
if reason == "switch" { #expect(assistant.conversation.composer.draft.isEmpty) }
}
}
@MainActor
private final class MobileComposerSpeechService: SpeechTranscriptionService {
var handler: (@MainActor (SpeechTranscriptionEvent) -> Void)?
func requestAuthorization() async throws {}
func start(locale: Locale, eventHandler: @escaping @MainActor (SpeechTranscriptionEvent) -> Void) throws {
handler = eventHandler
}
func finish() {}
func cancel() {}
}