diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt index bbf0891..c8af0c7 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -55,7 +55,11 @@ class GlassesApp : Application() { /** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */ val vertical = MutableStateFlow(1f) - /** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */ + /** + * Идёт распознавание речи (TripleClick стартовал; завершает фразу Click, + * отмена записи DoubleClick НЕ завершает сессию — сразу возвращает в + * «слушаю» снова; 30с тишины/выход из режима закрывают сессию). + */ val listening = MutableStateFlow(false) /** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */ diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt index 5db6702..ffa9bc6 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -368,12 +368,12 @@ class HostConnection( is SttCancel -> { when (msg.reason) { SttCancel.REASON_CANCEL -> { - // Явная отмена диктовки двойным тапом: закрыть микрофон, сбросить - // listening/thinking, НО остаться в режиме (показать последний ответ), - // микрофон заново НЕ открывать, из режима НЕ выходить. - log("stt", "отмена диктовки (двойной тап) — микрофон закрыт, остаёмся в режиме") - GlassesApp.instance.listening.value = false - GlassesApp.instance.thinking.value = false + // Явная отмена диктовки двойным тапом: очки САМИ уже вернулись + // в «слушаю» по жесту (TZ-ui-four п.1: отмена не закрывает чат, + // оверлей остаётся, микрофон готов к следующей записи). Это всего + // подтверждение, что телефон отменил STT-сессию (stt.reset()) — + // состояния (listening/thinking/оверлей) НЕ трогаем. + log("stt", "отмена диктовки (двойной тап) — остался в «слушаю», оверлей на месте") } SttCancel.REASON_TIMEOUT -> { // Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index f3ea153..6033a9b 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -312,12 +312,20 @@ class MainActivity : BaseEventActivity() { log("stt", "возврат в «слушаю» (SttCancel)") } - /** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */ + /** + * DoubleClick («слушаю»): отмена ТЕКУЩЕЙ записи (TZ-ui-four п.1): возвращаемся + * в «слушаю» — микрофон сразу готов к следующей записи, оверлей ОСТАЁТСЯ на + * экране (даже если истории диалога ещё нет — отмена не закрывает чат). + * Закрытие ассистента — двойной в «думаю»/«ответ виден» ([exitAssistantMode]). + */ private fun cancelDictation() { - log("gesture", "DoubleClick → отмена распознавания") + log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)") + val app = GlassesApp.instance sttMic.stop() - GlassesApp.instance.listening.value = false - GlassesApp.instance.thinking.value = false + app.sttText.value = "" + app.thinking.value = false + app.listening.value = true + sttMic.start(filesDir) { app.listening.value } lifecycleScope.launch { GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true)) } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt index 364a12e..09e41ff 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt @@ -64,7 +64,12 @@ sealed interface GestureDecision { /** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */ data object NewPhrase : GestureDecision - /** Двойной в «слушаю»: отмена распознавания, остаёмся в разговоре (StopStt(cancel=true)). */ + /** + * Двойной в «слушаю»: отмена ТЕКУЩЕЙ записи — возврат в «слушаю» + * ([afterCancelDictation]): микрофон снова готов к следующей записи, + * оверлей остаётся на экране даже при пустой истории диалога. + * Сторона очков: StopStt(cancel=true) на телефон. + */ data object CancelDictation : GestureDecision /** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */ @@ -125,6 +130,18 @@ fun gestureDecision( } } +/** + * Состояние после отмены записи ([GestureDecision.CancelDictation], двойной в «слушаю»): + * возврат в «слушаю» — микрофон снова открыт и готов к следующей записи, отменённый + * текст ([AssistantState.sttText]) сбросен. Диалог НЕ прерывается: оверлей остаётся + * на экране даже когда истории диалога ещё нет (empty [AssistantState] → [overlayVisible]=true, + * т.к. listening=true). Закрытие ассистента — отдельное явное действие: + * двойной в «думаю»/«ответ виден» ([GestureDecision.ExitMode]). + * Чистая функция — unit-тесты без Android. + */ +fun afterCancelDictation(state: AssistantState): AssistantState = + state.copy(listening = true, thinking = false, sttText = "") + /** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */ fun overlayVisible(state: AssistantState): Boolean = state.listening || state.thinking || state.assistantText.isNotBlank() diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt index 508af2e..cec5d40 100644 --- a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt @@ -150,10 +150,43 @@ class AssistantGesturesTest { fun cancelStaysInConversation() { val decision = gestureDecision(DoubleClick(), listening) assertEquals(GestureDecision.CancelDictation, decision) - // После отмены ответ предыдущей сессии виден — «ответ виден», выход — DoubleClick. - val after = listening.copy(listening = false, thinking = false, assistantText = "прошлый ответ") - assertEquals(AssistantMode.ANSWER, assistantMode(after)) - assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after)) + // После отмены (в т.ч. С первой записи, когда истории диалога ещё нет) + // возврат в «слушаю»: оверлей остаётся на экране, выхода нет. + val after = afterCancelDictation(listening) + assertEquals(AssistantMode.LISTENING, assistantMode(after)) + assertTrue(overlayVisible(after)) + // Двойной тап в «слушаю» после отмены — снова просто отмена новой записи. + assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), after)) + // Отмена посреди диалога: пользователь говорит новую фразу → ответ виден + // (listening уже закрыт отправкой) — выход (как и раньше) DoubleClick. + val afterWithAnswer = after.copy(listening = false, thinking = false, assistantText = "прошлый ответ") + assertEquals(AssistantMode.ANSWER, assistantMode(afterWithAnswer)) + assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), afterWithAnswer)) + } + + /** Отмена первой записи (пустая история): оверлей НЕ закрывается (TZ-ui-four п.1). */ + @Test + fun cancelFirstRecordingKeepsOverlay() { + // Чистый «кино»-снимок → «слушаю» (вызов ассистента) → отмена записи. + val listeningNow = movie.copy(listening = true) + val cancelled = afterCancelDictation(listeningNow) + assertTrue(cancelled.assistantText.isBlank()) + assertTrue(cancelled.listening) + // Оверлей виден, хотя ни фразы, ни ответа нет — «слушай готов». + assertTrue(overlayVisible(cancelled)) + // И только явный выход (после диалога) прячет оверлей. + val exited = cancelled.copy(listening = false, thinking = false, assistantText = "ответ") + val closed = exited.copy(listening = false, thinking = false, assistantText = "") + assertFalse(overlayVisible(closed)) + assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), closed)) + } + + /** Отмена сбрасывает накопленный текст текущей (отменяемой) записи. */ + @Test + fun cancelDictationClearsCancelledText() { + val after = afterCancelDictation(listening.copy(sttText = "слова до отмены")) + assertEquals("", after.sttText) + assertTrue(after.listening) } // ————— оверлей —————