Compare commits
3 Commits
da8182443c
...
cf8c5ef604
| Author | SHA1 | Date | |
|---|---|---|---|
| cf8c5ef604 | |||
| 236ff18543 | |||
| 412e093174 |
@@ -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()
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ import pw.binom.viewmate.glasses.gestures.overlayVisible
|
|||||||
import pw.binom.viewmate.glasses.gestures.scrollOffset
|
import pw.binom.viewmate.glasses.gestures.scrollOffset
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
|
* Оверлей режима общения с ассистентом: непрозрачным чёрным накрывает ВСЁ вьюпорт
|
||||||
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
|
* каждого глаза (100% площади) — независимо от масштаба/высоты видео (регулятор
|
||||||
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
|
* «Высота»): полная замена картины оверлеем (TZ-ui-four п.4). Содержимое остаётся
|
||||||
|
* в том же scale-контейнере, что у экранов. Режим общения явный
|
||||||
|
* (docs/gestures-and-modes.md). По глазам ([Binocular]), как все экраны.
|
||||||
|
* Состояния: «слушаю»/«думаю...» — заголовок,
|
||||||
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
|
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
|
||||||
* [GlassesApp.answerScrollDelta]).
|
* [GlassesApp.answerScrollDelta]).
|
||||||
*/
|
*/
|
||||||
@@ -81,7 +84,13 @@ fun AssistantOverlay() {
|
|||||||
overlayBox(app, state, chatHistory, scrollState)
|
overlayBox(app, state, chatHistory, scrollState)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */
|
/**
|
||||||
|
* Поглазник: затемняющий слой накрывает ВСЁ поле глаза (100% viewport)
|
||||||
|
* непрозрачным чёрным — поверх SurfaceView-видео и любого положения регулятора
|
||||||
|
* «Высота» (TZ-ui-four п.4: 72%-полупрозрачный слой поверх контейнера видео
|
||||||
|
* оставался «проглядывающим» — видео светилось/не затемнялось). Контент
|
||||||
|
* (текст/редактирование) остаётся в том же scale/offset-контейнере, что у экранов.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun overlayBox(
|
private fun overlayBox(
|
||||||
app: GlassesApp,
|
app: GlassesApp,
|
||||||
@@ -98,24 +107,29 @@ private fun overlayBox(
|
|||||||
val containerH = (screenHeightPx * scale).toInt()
|
val containerH = (screenHeightPx * scale).toInt()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(containerW.px)
|
.fillMaxSize()
|
||||||
.height(containerH.px)
|
.background(Color.Black),
|
||||||
.offset(
|
|
||||||
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
|
||||||
// вертикаль (vertical) — только для видео; оверлей всегда у верха
|
|
||||||
)
|
|
||||||
.background(Color.Black.copy(alpha = 0.72f)),
|
|
||||||
) {
|
) {
|
||||||
AssistantOverlayContent(state, chatHistory, scrollState)
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(containerW.px)
|
||||||
|
.height(containerH.px)
|
||||||
|
.offset(
|
||||||
|
x = scaleOffsetX(side, screenWidthPx, scale).px,
|
||||||
|
// вертикаль (vertical) — только для видео; оверлей всегда у верха
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
AssistantOverlayContent(state, chatHistory, scrollState)
|
||||||
|
|
||||||
if (state.listening) {
|
if (state.listening) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(top = 12.dp, end = 12.dp)
|
.padding(top = 12.dp, end = 12.dp)
|
||||||
.size(16.dp)
|
.size(16.dp)
|
||||||
.background(Color(0xFFFF3B30), CircleShape),
|
.background(Color(0xFFFF3B30), CircleShape),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ————— оверлей —————
|
// ————— оверлей —————
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package pw.binom.viewmate.phone.ui
|
package pw.binom.viewmate.phone.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -33,6 +34,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -48,7 +51,9 @@ import pw.binom.viewmate.phone.agent.Session
|
|||||||
/**
|
/**
|
||||||
* Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в
|
* Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в
|
||||||
* локального ассистента (та же очередь, что и голос с очков). Управление
|
* локального ассистента (та же очередь, что и голос с очков). Управление
|
||||||
* сессиями: новая, переключение (выпадающий список), сброс текущей.
|
* сессиями: новая, переключение (выпадающий список ИЛИ ступенчатый горизонтальный
|
||||||
|
* свайп ленты сообщений — на одну сессию за жест, без инерции; TZ-ui-four п.3),
|
||||||
|
* сброс текущей.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen() {
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -173,7 +197,31 @@ fun ChatScreen() {
|
|||||||
Spacer(Modifier.height(4.dp))
|
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()) {
|
if (messages.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Спроси что-нибудь — например, «сколько сейчас время?»",
|
text = "Спроси что-нибудь — например, «сколько сейчас время?»",
|
||||||
@@ -228,7 +276,9 @@ private fun MessageBubble(role: ChatRole, text: String) {
|
|||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
color = PhoneText,
|
color = PhoneText,
|
||||||
fontSize = 14.sp,
|
// Базовый шрифт сообщений (TZ-ui-four п.2): крупнее, чтобы читалось
|
||||||
|
// без напряжения (14.sp → 20.sp, ~+43%); вёртка баллонов не менялась.
|
||||||
|
fontSize = 20.sp,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
.background(
|
.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