From c907cbd83ea4f7c56d0a891a2a567049b503c021 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 27 Sep 2026 19:25:37 +0800 Subject: [PATCH] fix(ios): keep send available during voice input --- TFI/Features/AI/MobileAssistantComposer.swift | 67 +++++++---- TFITests/MobileAssistantSpeechTests.swift | 107 ++++++++++++++++++ 2 files changed, 149 insertions(+), 25 deletions(-) create mode 100644 TFITests/MobileAssistantSpeechTests.swift diff --git a/TFI/Features/AI/MobileAssistantComposer.swift b/TFI/Features/AI/MobileAssistantComposer.swift index 208bca87..7afde278 100644 --- a/TFI/Features/AI/MobileAssistantComposer.swift +++ b/TFI/Features/AI/MobileAssistantComposer.swift @@ -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(@ViewBuilder status: () -> Status) -> some View { + private func speechControls( + 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) diff --git a/TFITests/MobileAssistantSpeechTests.swift b/TFITests/MobileAssistantSpeechTests.swift new file mode 100644 index 00000000..8c47329b --- /dev/null +++ b/TFITests/MobileAssistantSpeechTests.swift @@ -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() {} +}