Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf8c5ef604 | |||
| 236ff18543 | |||
| 412e093174 |
@@ -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. */
|
||||
|
||||
@@ -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с: пользователь молчит/ушёл — ВЫХОДИМ из
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+18
-1
@@ -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()
|
||||
|
||||
@@ -36,9 +36,12 @@ import pw.binom.viewmate.glasses.gestures.overlayVisible
|
||||
import pw.binom.viewmate.glasses.gestures.scrollOffset
|
||||
|
||||
/**
|
||||
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
|
||||
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
|
||||
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
|
||||
* Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
|
||||
* каждого глаза (100% площади) — независимо от масштаба/высоты видео (регулятор
|
||||
* «Высота»): полная замена картины оверлеем (TZ-ui-four п.4). Содержимое остаётся
|
||||
* в том же scale-контейнере, что у экранов. Режим общения явный
|
||||
* (docs/gestures-and-modes.md). По глазам ([Binocular]), как все экраны.
|
||||
* Состояния: «слушаю»/«думаю...» — заголовок,
|
||||
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
|
||||
* [GlassesApp.answerScrollDelta]).
|
||||
*/
|
||||
@@ -81,7 +84,13 @@ fun AssistantOverlay() {
|
||||
overlayBox(app, state, chatHistory, scrollState)
|
||||
}
|
||||
|
||||
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */
|
||||
/**
|
||||
* Поглазник: затемняющий слой накрывает ВСЁ поле глаза (100% viewport)
|
||||
* непрозрачным чёрным — поверх SurfaceView-видео и любого положения регулятора
|
||||
* «Высота» (TZ-ui-four п.4: 72%-полупрозрачный слой поверх контейнера видео
|
||||
* оставался «проглядывающим» — видео светилось/не затемнялось). Контент
|
||||
* (текст/редактирование) остаётся в том же scale/offset-контейнере, что у экранов.
|
||||
*/
|
||||
@Composable
|
||||
private fun overlayBox(
|
||||
app: GlassesApp,
|
||||
@@ -96,6 +105,11 @@ private fun overlayBox(
|
||||
val side = BinocularScreenSide.current
|
||||
val containerW = (screenWidthPx * scale).toInt()
|
||||
val containerH = (screenHeightPx * scale).toInt()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(containerW.px)
|
||||
@@ -103,8 +117,7 @@ private fun overlayBox(
|
||||
.offset(
|
||||
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
||||
// вертикаль (vertical) — только для видео; оверлей всегда у верха
|
||||
)
|
||||
.background(Color.Black.copy(alpha = 0.72f)),
|
||||
),
|
||||
) {
|
||||
AssistantOverlayContent(state, chatHistory, scrollState)
|
||||
|
||||
@@ -120,6 +133,7 @@ private fun overlayBox(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantOverlayContent(
|
||||
|
||||
+37
-4
@@ -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)
|
||||
}
|
||||
|
||||
// ————— оверлей —————
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -33,6 +34,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -48,7 +51,9 @@ import pw.binom.viewmate.phone.agent.Session
|
||||
/**
|
||||
* Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в
|
||||
* локального ассистента (та же очередь, что и голос с очков). Управление
|
||||
* сессиями: новая, переключение (выпадающий список), сброс текущей.
|
||||
* сессиями: новая, переключение (выпадающий список ИЛИ ступенчатый горизонтальный
|
||||
* свайп ленты сообщений — на одну сессию за жест, без инерции; TZ-ui-four п.3),
|
||||
* сброс текущей.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatScreen() {
|
||||
@@ -104,6 +109,25 @@ fun ChatScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Ступенчатое переключение сессий свайпом (TZ-ui-four п.3): фиксированный
|
||||
// порог смещения в px (плотность экрана).
|
||||
val thresholdPx = with(LocalDensity.current) { CHAT_SESSION_SWIPE_THRESHOLD.toPx() }
|
||||
|
||||
/**
|
||||
* Ступенчатое переключение: ровно ОДИН шаг (+1 — следующая, −1 — предыдущая),
|
||||
* без перескока сразу на несколько сессий, без инерции/флинга;
|
||||
* за границей списка — ничего.
|
||||
*/
|
||||
fun switchSessionBy(delta: Int) {
|
||||
if (sessions.size < 2) return
|
||||
val idx = sessions.indexOfFirst { it.id == activeId }.let { if (it >= 0) it else 0 }
|
||||
val target = idx + delta
|
||||
if (target < 0 || target >= sessions.size) return
|
||||
val s = sessions[target]
|
||||
activeId = s.id
|
||||
app.selectChatSession(s.id)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -173,7 +197,31 @@ fun ChatScreen() {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Лента сообщений
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
// Ступенчатая смена сессии (TZ-ui-four п.3): горизонтальный свайп
|
||||
// ленты за фиксированный порог = ±одна сессия (свайп влево —
|
||||
// следующая, вправо — предыдущая), мгновенно, без физики/инерции.
|
||||
// foundation 1.8: onHorizontalDrag даёт (change, суммарный сдвиг x с
|
||||
// начала жеста) — берём суммарный сдвиг без накопления по кадрам.
|
||||
.pointerInput(sessions.size) {
|
||||
var draggedPx = 0f
|
||||
detectHorizontalDragGestures(
|
||||
onHorizontalDrag = { _, amount -> draggedPx = amount },
|
||||
onDragCancel = { draggedPx = 0f },
|
||||
onDragEnd = {
|
||||
when {
|
||||
draggedPx <= -thresholdPx -> switchSessionBy(1)
|
||||
draggedPx >= thresholdPx -> switchSessionBy(-1)
|
||||
else -> Unit
|
||||
}
|
||||
draggedPx = 0f
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
if (messages.isEmpty()) {
|
||||
Text(
|
||||
text = "Спроси что-нибудь — например, «сколько сейчас время?»",
|
||||
@@ -228,7 +276,9 @@ private fun MessageBubble(role: ChatRole, text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
color = PhoneText,
|
||||
fontSize = 14.sp,
|
||||
// Базовый шрифт сообщений (TZ-ui-four п.2): крупнее, чтобы читалось
|
||||
// без напряжения (14.sp → 20.sp, ~+43%); вёртка баллонов не менялась.
|
||||
fontSize = 20.sp,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
.background(
|
||||
@@ -239,3 +289,9 @@ private fun MessageBubble(role: ChatRole, text: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Фиксированный порог ступенчатого свайпа переключения сессий (TZ-ui-four п.3):
|
||||
* жест «перетолкал» ленту на него — смена ровно на одну сессию; меньше — назад.
|
||||
*/
|
||||
private val CHAT_SESSION_SWIPE_THRESHOLD = 120.dp
|
||||
|
||||
Reference in New Issue
Block a user