fix(ios): keep send available during voice input
This commit is contained in:
1 parent
c1ae0dc0a2
commit
c907cbd83e
2 files changed
+149
-25
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
Reference in new issue
Block a user