ui(glasses): отмена первой записи не закрывает оверлей — возврат в «слушаю», диалог жив даже при пустой истории; выход из режима — только явный жест ExitMode

This commit is contained in:
2026-08-27 18:46:29 +03:00
parent da8182443c
commit 412e093174
5 changed files with 78 additions and 16 deletions
@@ -55,7 +55,11 @@ class GlassesApp : Application() {
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */ /** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
val vertical = MutableStateFlow(1f) val vertical = MutableStateFlow(1f)
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */ /**
* Идёт распознавание речи (TripleClick стартовал; завершает фразу Click,
* отмена записи DoubleClick НЕ завершает сессию — сразу возвращает в
* «слушаю» снова; 30с тишины/выход из режима закрывают сессию).
*/
val listening = MutableStateFlow(false) val listening = MutableStateFlow(false)
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */ /** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
@@ -368,12 +368,12 @@ class HostConnection(
is SttCancel -> { is SttCancel -> {
when (msg.reason) { when (msg.reason) {
SttCancel.REASON_CANCEL -> { SttCancel.REASON_CANCEL -> {
// Явная отмена диктовки двойным тапом: закрыть микрофон, сбросить // Явная отмена диктовки двойным тапом: очки САМИ уже вернулись
// listening/thinking, НО остаться в режиме (показать последний ответ), // в «слушаю» по жесту (TZ-ui-four п.1: отмена не закрывает чат,
// микрофон заново НЕ открывать, из режима НЕ выходить. // оверлей остаётся, микрофон готов к следующей записи). Это всего
log("stt", "отмена диктовки (двойной тап) — микрофон закрыт, остаёмся в режиме") // подтверждение, что телефон отменил STT-сессию (stt.reset()) —
GlassesApp.instance.listening.value = false // состояния (listening/thinking/оверлей) НЕ трогаем.
GlassesApp.instance.thinking.value = false log("stt", "отмена диктовки (двойной тап) — остался в «слушаю», оверлей на месте")
} }
SttCancel.REASON_TIMEOUT -> { SttCancel.REASON_TIMEOUT -> {
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из // Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
@@ -312,12 +312,20 @@ class MainActivity : BaseEventActivity() {
log("stt", "возврат в «слушаю» (SttCancel)") log("stt", "возврат в «слушаю» (SttCancel)")
} }
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */ /**
* DoubleClick («слушаю»): отмена ТЕКУЩЕЙ записи (TZ-ui-four п.1): возвращаемся
* в «слушаю» — микрофон сразу готов к следующей записи, оверлей ОСТАЁТСЯ на
* экране (даже если истории диалога ещё нет — отмена не закрывает чат).
* Закрытие ассистента — двойной в «думаю»/«ответ виден» ([exitAssistantMode]).
*/
private fun cancelDictation() { private fun cancelDictation() {
log("gesture", "DoubleClick → отмена распознавания") log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)")
val app = GlassesApp.instance
sttMic.stop() sttMic.stop()
GlassesApp.instance.listening.value = false app.sttText.value = ""
GlassesApp.instance.thinking.value = false app.thinking.value = false
app.listening.value = true
sttMic.start(filesDir) { app.listening.value }
lifecycleScope.launch { lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true)) GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
} }
@@ -64,7 +64,12 @@ sealed interface GestureDecision {
/** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */ /** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */
data object NewPhrase : GestureDecision data object NewPhrase : GestureDecision
/** Двойной в «слушаю»: отмена распознавания, остаёмся в разговоре (StopStt(cancel=true)). */ /**
* Двойной в «слушаю»: отмена ТЕКУЩЕЙ записи — возврат в «слушаю»
* ([afterCancelDictation]): микрофон снова готов к следующей записи,
* оверлей остаётся на экране даже при пустой истории диалога.
* Сторона очков: StopStt(cancel=true) на телефон.
*/
data object CancelDictation : GestureDecision 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 = fun overlayVisible(state: AssistantState): Boolean =
state.listening || state.thinking || state.assistantText.isNotBlank() state.listening || state.thinking || state.assistantText.isNotBlank()
@@ -150,10 +150,43 @@ class AssistantGesturesTest {
fun cancelStaysInConversation() { fun cancelStaysInConversation() {
val decision = gestureDecision(DoubleClick(), listening) val decision = gestureDecision(DoubleClick(), listening)
assertEquals(GestureDecision.CancelDictation, decision) assertEquals(GestureDecision.CancelDictation, decision)
// После отмены ответ предыдущей сессии виден — «ответ виден», выход — DoubleClick. // После отмены (в т.ч. С первой записи, когда истории диалога ещё нет)
val after = listening.copy(listening = false, thinking = false, assistantText = "прошлый ответ") // возврат в «слушаю»: оверлей остаётся на экране, выхода нет.
assertEquals(AssistantMode.ANSWER, assistantMode(after)) val after = afterCancelDictation(listening)
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), after)) 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)
} }
// ————— оверлей ————— // ————— оверлей —————