ui(glasses): отмена первой записи не закрывает оверлей — возврат в «слушаю», диалог жив даже при пустой истории; выход из режима — только явный жест ExitMode
This commit is contained in:
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-1
@@ -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()
|
||||||
|
|||||||
+37
-4
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ————— оверлей —————
|
// ————— оверлей —————
|
||||||
|
|||||||
Reference in New Issue
Block a user