Очки: жесты и режимы общения с ассистентом (по docs/gestures-and-modes.md)

- AssistantMode: NONE/LISTENING/THINKING/ANSWER + чистая gestureDecision (матрица жестов)
- Клик в «ответ виден» = новая фраза; DoubleClick = выход (кино на паузе); свайпы = скролл
- Плавный скролл ответа: SlideContinuous delta → answerScrollDelta → scrollOffset (зажим 0..max)
- Индикатор «думаю...» (thinking, сброс на ShowText)
- AssistantOverlay: затемнение 72%, по глазам, скролл-аккумулятор
- Тесты: 22 (AssistantGesturesTest) — glasses 91 зелёных
This commit is contained in:
2026-08-23 01:55:24 +03:00
parent f36d04adc1
commit f75e672819
6 changed files with 664 additions and 90 deletions
@@ -0,0 +1,151 @@
package pw.binom.viewmate.glasses.gestures
import com.rayneo.arsdk.android.touch.TempleAction
import pw.binom.viewmate.core.MediaCommand
/**
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
* «кино» → «слушаю» → («думаю») → «ответ виден» → выход / «новая фраза».
*
* @NONE — кино: ассистент не вызван.
* @LISTENING — слушаю: микрофон открыт ([AssistantState.listening]=true).
* @THINKING — думает: фраза отправлена, ждём ответ (ShowText).
* @ANSWER — ответ виден: listening=false и assistantText непуст.
*/
enum class AssistantMode { NONE, LISTENING, THINKING, ANSWER, }
/** Снимок состояния общения для карты жеста (чистые данные, без Android). */
data class AssistantState(
/** Микрофон открыт (TripleClick → слушаю). */
val listening: Boolean = false,
/** «Думаю»: фраза отправлена (Click в «слушаю») — ждём ShowText. */
val thinking: Boolean = false,
/** Накопленные фразы (SttPhrase, full). */
val sttText: String = "",
/** Ответ ассистента (ShowText). */
val assistantText: String = "",
)
/**
* Режим по снимку. Порядок — по ТЗ: «ответ виден» = (!listening && assistantText непуст),
* он приоритетнее «думаю»; listening=true всегда побеждает (микروفон открыт —
* жесты слушают матрицу «слушаю», даже если thinking при этом стоит).
*/
fun assistantMode(state: AssistantState): AssistantMode = when {
state.listening -> AssistantMode.LISTENING
state.assistantText.isNotBlank() -> AssistantMode.ANSWER
state.thinking -> AssistantMode.THINKING
else -> AssistantMode.NONE
}
/**
* Решение по жесту: что сделать. Чистое (без побочных эффектов) — исполняет MainActivity.
* Матрица (docs/gestures-and-modes.md):
*
* | Режим | Клик | Двойной | Тройной | Свайпы |
* |------------------|--------------------|-----------|---------|----------------------|
* | Кино | плей/пауза | игнор | вызов | ±30с |
* | Слушаю | отправить фразу | отмена | игнор | игнор |
* | Думаю/ответ виден| новая фраза | выход | игнор | (ответ): скролл |
*/
sealed interface GestureDecision {
/** Жест в данном режиме не назначен (напр. DoubleClick в кино, свайпы в «слушаю»). */
data object Ignore : GestureDecision
/** TripleClick (кино): пауза фильма + открыть микрофон. */
data object CallAssistant : GestureDecision
/** Клик в кино: плей/пауза (как сегодня, MediaCommand.TOGGLE). */
data object TogglePlayback : GestureDecision
/** Клик в «слушаю»: закрыть микрофон, отправить стоп-STT (фраза на телефон). */
data object SendPhrase : GestureDecision
/** Клик в «думаю»/«ответ виден»: стереть ответ, открыть микрофон (новая фраза). */
data object NewPhrase : GestureDecision
/** Двойной в «слушаю»: отмена распознавания, остаёмся в разговоре (StopStt(cancel=true)). */
data object CancelDictation : GestureDecision
/** Двойной в «думаю»/«ответ виден»: выход из режима — закрыть оверлей, фильм на паузе. */
data object ExitMode : GestureDecision
/** Свайпы в кино: seek на ±[seekMs] (по [seekStepMs]). */
data class Seek(val command: MediaCommand, val seekMs: Long) : GestureDecision
/** Свайпы при «ответе виден»: дельта скролла ответа, px. Положительная — к концу текста. */
data class ScrollAnswer(val delta: Float) : GestureDecision
}
/**
* Карту жестов по режиму ([AssistantMode]). Чистая функция — unit-тесты без Android.
*
* @seekStepMs шаг сета в кино (по умолчанию 30 000 мс — как [templeActionToCommand]).
* @pageDeltaPx дельта дискретного свайпа при «ответе виден»; плавный [TempleAction.SlideContinuous]
* даёт свою дельту (знак дельты проверяем на устройстве: если скролл идёт в обратную сторону —
* меняем знак здесь, в одной точке).
*/
fun gestureDecision(
action: TempleAction,
state: AssistantState,
seekStepMs: Long = 30_000,
pageDeltaPx: Float = 200f,
): GestureDecision = when (val mode = assistantMode(state)) {
// Кино: как сегодня (templeActionToCommand), плюс Click/TripleClick/DoubleClick по матрице.
AssistantMode.NONE -> when (action) {
is TempleAction.Click -> GestureDecision.TogglePlayback
is TempleAction.TripleClick -> GestureDecision.CallAssistant
else ->
templeActionToCommand(action, seekStepMs)?.let { GestureDecision.Seek(it.command, it.seekMs) }
?: GestureDecision.Ignore
}
AssistantMode.LISTENING -> when (action) {
is TempleAction.Click -> GestureDecision.SendPhrase
is TempleAction.DoubleClick -> GestureDecision.CancelDictation
else -> GestureDecision.Ignore
}
AssistantMode.THINKING -> when (action) {
is TempleAction.Click -> GestureDecision.NewPhrase
is TempleAction.DoubleClick -> GestureDecision.ExitMode
else -> GestureDecision.Ignore
}
AssistantMode.ANSWER -> when (action) {
is TempleAction.Click -> GestureDecision.NewPhrase
is TempleAction.DoubleClick -> GestureDecision.ExitMode
is TempleAction.SlideForward -> GestureDecision.ScrollAnswer(pageDeltaPx)
is TempleAction.SlideBackward -> GestureDecision.ScrollAnswer(-pageDeltaPx)
is TempleAction.SlideContinuous ->
// Плавный скролл: накопленная дельта шлейфа. Запасной (дискретный) вариант —
// два строка выше.
GestureDecision.ScrollAnswer(action.delta)
else -> GestureDecision.Ignore
}
}
/** Оверлей общения виден: слушаю / думаю / ответ на экране. В кино — нет. */
fun overlayVisible(state: AssistantState): Boolean =
state.listening || state.thinking || state.assistantText.isNotBlank()
/**
* Заголовок оверлея (над ответом). null — только ответ.
* - «думаю...» — ждём ShowText (вместо «слушаю...» после отправки фразы, даже если
* listening ещё true — микрофон уже закрыт);
* - «слушаю...»/фраза — микрофон открыт;
* - при «ответе виден» — последняя фраза как контекст (или null).
*/
fun overlayHeader(state: AssistantState): String? = when {
state.thinking && state.assistantText.isBlank() -> "думаю..."
state.listening -> state.sttText.ifBlank { "слушаю..." }
else -> state.sttText.takeIf { it.isNotBlank() }
}
/**
* Позиция скролла ответа после применения дельты [delta] (px): зажим в [0, maxScroll].
* [currentOffset], [maxScroll] — [androidx.compose.foundation.ScrollState.value/maxValue].
* Чистая функция — unit-тесты без Android.
*/
fun scrollOffset(currentOffset: Int, delta: Float, maxScroll: Int): Int =
(currentOffset + delta).toInt().coerceIn(0, maxScroll)