Очки: жесты и режимы общения с ассистентом (по 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:
@@ -58,6 +58,20 @@ class GlassesApp : Application() {
|
||||
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
|
||||
val assistantText = MutableStateFlow("")
|
||||
|
||||
/**
|
||||
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
|
||||
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
|
||||
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...».
|
||||
*/
|
||||
val thinking = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
|
||||
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
|
||||
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
|
||||
*/
|
||||
val answerScrollDelta = MutableStateFlow(0f)
|
||||
|
||||
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
||||
var mainActivity: MainActivity? = null
|
||||
|
||||
|
||||
@@ -333,6 +333,8 @@ class HostConnection(
|
||||
|
||||
is ShowText -> {
|
||||
addMessage("ShowText: ${msg.text}")
|
||||
GlassesApp.instance.thinking.value = false
|
||||
GlassesApp.instance.answerScrollDelta.value = 0f
|
||||
GlassesApp.instance.assistantText.value = msg.text
|
||||
}
|
||||
|
||||
@@ -351,6 +353,7 @@ class HostConnection(
|
||||
is SttCancel -> {
|
||||
log("stt", "отменено")
|
||||
GlassesApp.instance.listening.value = false
|
||||
GlassesApp.instance.thinking.value = false
|
||||
}
|
||||
|
||||
is Shutdown -> {
|
||||
|
||||
@@ -19,7 +19,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -37,7 +36,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@@ -52,10 +50,14 @@ import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
import pw.binom.viewmate.core.protocol.SttAudio
|
||||
import pw.binom.viewmate.core.protocol.StopStt
|
||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||
import pw.binom.viewmate.glasses.gestures.GestureDecision
|
||||
import pw.binom.viewmate.glasses.gestures.actionName
|
||||
import pw.binom.viewmate.glasses.gestures.templeActionToCommand
|
||||
import pw.binom.viewmate.glasses.gestures.assistantMode
|
||||
import pw.binom.viewmate.glasses.gestures.gestureDecision
|
||||
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
||||
import pw.binom.viewmate.glasses.stt.SttMicStream
|
||||
import pw.binom.viewmate.glasses.ui.AssistantOverlay
|
||||
import pw.binom.viewmate.glasses.ui.Binocular
|
||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
|
||||
@@ -80,9 +82,9 @@ class MainActivity : BaseEventActivity() {
|
||||
private val debugReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.getStringExtra("type")) {
|
||||
"triple_click" -> handleTripleClick()
|
||||
"click" -> handleClick()
|
||||
"double_click" -> handleDoubleClick()
|
||||
"triple_click" -> onTempleAction(TempleAction.TripleClick())
|
||||
"click" -> onTempleAction(TempleAction.Click())
|
||||
"double_click" -> onTempleAction(TempleAction.DoubleClick())
|
||||
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
|
||||
}
|
||||
}
|
||||
@@ -137,6 +139,9 @@ class MainActivity : BaseEventActivity() {
|
||||
} else {
|
||||
ConnectionScreen()
|
||||
}
|
||||
// Оверлей режима общения: поверх любого экрана (затемняет кино),
|
||||
// виден в «слушаю»/«думаю»/«ответ виден».
|
||||
AssistantOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,11 +195,6 @@ class MainActivity : BaseEventActivity() {
|
||||
}, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick —
|
||||
* голосовой ввод (безусловный вызов), Click во время прослушивания —
|
||||
* завершить распознавание, DoubleClick — отмена.
|
||||
*/
|
||||
private fun requestBtPermissions() {
|
||||
if (!GlassesConfig.USE_BLUETOOTH) return
|
||||
val granted = BtGlassesTransport.runtimePermissions().all {
|
||||
@@ -205,73 +205,127 @@ class MainActivity : BaseEventActivity() {
|
||||
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
|
||||
}
|
||||
|
||||
/** События трекпада (SDK) → [onTempleAction] — единственная точка входа жестов. */
|
||||
private fun collectTempleActions() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
templeActionViewModel.state.collect { action ->
|
||||
when (action) {
|
||||
is TempleAction.TripleClick -> handleTripleClick()
|
||||
is TempleAction.Click -> handleClick()
|
||||
is TempleAction.DoubleClick -> handleDoubleClick()
|
||||
is TempleAction.SlideForward, is TempleAction.SlideBackward -> {
|
||||
val cmd = templeActionToCommand(action)
|
||||
log("gesture", "жест: ${actionName(action)} → ${cmd?.command}")
|
||||
if (cmd != null) {
|
||||
GlassesApp.instance.movieController.applyMediaCommand(cmd) {
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> log("gesture", "жест (резерв): ${actionName(action)}")
|
||||
}
|
||||
onTempleAction(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** TripleClick: если не слушаем — пауза кино + старт распознавания. */
|
||||
private fun handleTripleClick() {
|
||||
if (GlassesApp.instance.listening.value) {
|
||||
/**
|
||||
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
|
||||
* (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку
|
||||
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже.
|
||||
*/
|
||||
private fun onTempleAction(action: TempleAction) {
|
||||
val app = GlassesApp.instance
|
||||
val state = AssistantState(
|
||||
listening = app.listening.value,
|
||||
thinking = app.thinking.value,
|
||||
sttText = app.sttText.value,
|
||||
assistantText = app.assistantText.value,
|
||||
)
|
||||
when (val decision = gestureDecision(action, state)) {
|
||||
GestureDecision.Ignore ->
|
||||
log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})")
|
||||
GestureDecision.CallAssistant -> startAssistantMode()
|
||||
GestureDecision.TogglePlayback -> togglePlayback()
|
||||
GestureDecision.SendPhrase -> sendPhrase()
|
||||
GestureDecision.NewPhrase -> startNewPhrase()
|
||||
GestureDecision.CancelDictation -> cancelDictation()
|
||||
GestureDecision.ExitMode -> exitAssistantMode()
|
||||
is GestureDecision.Seek -> seekBy(decision)
|
||||
is GestureDecision.ScrollAnswer -> {
|
||||
val acc = app.answerScrollDelta.value + decision.delta
|
||||
log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px")
|
||||
app.answerScrollDelta.value = acc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */
|
||||
private fun startAssistantMode() {
|
||||
val app = GlassesApp.instance
|
||||
if (app.listening.value) {
|
||||
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
|
||||
return
|
||||
}
|
||||
log("stt", "ассистент: слушаю")
|
||||
if (GlassesApp.instance.movieController.pauseIfPlaying()) {
|
||||
log("stt", "кино на паузе (TripleClick)")
|
||||
if (app.movieController.pauseIfPlaying()) {
|
||||
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
||||
}
|
||||
GlassesApp.instance.listening.value = true
|
||||
GlassesApp.instance.sttText.value = ""
|
||||
GlassesApp.instance.assistantText.value = ""
|
||||
sttMic.start(filesDir) { GlassesApp.instance.listening.value }
|
||||
app.listening.value = true
|
||||
app.thinking.value = false
|
||||
app.sttText.value = ""
|
||||
app.assistantText.value = ""
|
||||
app.answerScrollDelta.value = 0f
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
}
|
||||
|
||||
/** Click: во время прослушивания — завершить распознавание; иначе пауза/плей. */
|
||||
private fun handleClick() {
|
||||
if (GlassesApp.instance.listening.value) {
|
||||
log("gesture", "Click → завершить распознавание")
|
||||
sttMic.stop()
|
||||
lifecycleScope.launch {
|
||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
||||
}
|
||||
return
|
||||
}
|
||||
/** Click (кино): плей/пауза — как сегодня. */
|
||||
private fun togglePlayback() {
|
||||
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
|
||||
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
}
|
||||
}
|
||||
|
||||
/** DoubleClick: во время прослушивания — отмена; иначе — резерв. */
|
||||
private fun handleDoubleClick() {
|
||||
if (GlassesApp.instance.listening.value) {
|
||||
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
|
||||
private fun sendPhrase() {
|
||||
log("gesture", "Click → отправить фразу")
|
||||
sttMic.stop()
|
||||
GlassesApp.instance.thinking.value = true
|
||||
lifecycleScope.launch {
|
||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
||||
}
|
||||
}
|
||||
|
||||
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
|
||||
private fun startNewPhrase() {
|
||||
log("gesture", "Click → новая фраза (ответ сотёрт, микрофон открыт)")
|
||||
val app = GlassesApp.instance
|
||||
app.assistantText.value = ""
|
||||
app.sttText.value = ""
|
||||
app.thinking.value = false
|
||||
app.answerScrollDelta.value = 0f
|
||||
app.listening.value = true
|
||||
sttMic.start(filesDir) { app.listening.value }
|
||||
}
|
||||
|
||||
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
|
||||
private fun cancelDictation() {
|
||||
log("gesture", "DoubleClick → отмена распознавания")
|
||||
sttMic.stop()
|
||||
GlassesApp.instance.thinking.value = false
|
||||
lifecycleScope.launch {
|
||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
||||
}
|
||||
return
|
||||
}
|
||||
log("gesture", "жест (резерв): DoubleClick")
|
||||
|
||||
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
|
||||
private fun exitAssistantMode() {
|
||||
log("gesture", "DoubleClick → выход из режима (кино остаётся на паузе)")
|
||||
sttMic.stop()
|
||||
val app = GlassesApp.instance
|
||||
app.listening.value = false
|
||||
app.thinking.value = false
|
||||
app.sttText.value = ""
|
||||
app.assistantText.value = ""
|
||||
app.answerScrollDelta.value = 0f
|
||||
}
|
||||
|
||||
/** Свайпы (кино): seek на ±шаг (маппинг — templeActionToCommand через [GestureDecision.Seek]). */
|
||||
private fun seekBy(seek: GestureDecision.Seek) {
|
||||
log("gesture", "жест: свайп → ${seek.command}")
|
||||
GlassesApp.instance.movieController.applyMediaCommand(
|
||||
MediaCommandMsg(seek.command, seek.seekMs)
|
||||
) {
|
||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -411,41 +465,7 @@ fun ConnectionScreen() {
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
||||
scale = scale,
|
||||
)
|
||||
|
||||
// Оверлей распознавания: затемнение + накопленный текст (поверх всего).
|
||||
// Остаётся и после завершения распознавания, пока не стёрт ответ ассистента
|
||||
// (assistantText) — ответ читается до новой сессии.
|
||||
val listening by GlassesApp.instance.listening.collectAsState()
|
||||
val sttText by GlassesApp.instance.sttText.collectAsState()
|
||||
val assistantText by GlassesApp.instance.assistantText.collectAsState()
|
||||
if (listening || assistantText.isNotBlank()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.72f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center).padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
if (sttText.isBlank()) "слушаю..." else sttText,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
if (assistantText.isNotBlank()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
assistantText,
|
||||
color = Color(0xFFB0BEC5),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,131 @@
|
||||
package pw.binom.viewmate.glasses.ui
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import pw.binom.viewmate.glasses.GlassesApp
|
||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||
import pw.binom.viewmate.glasses.gestures.overlayHeader
|
||||
import pw.binom.viewmate.glasses.gestures.overlayVisible
|
||||
import pw.binom.viewmate.glasses.gestures.scrollOffset
|
||||
|
||||
/**
|
||||
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
|
||||
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
|
||||
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
|
||||
* «ответ виден» — прокручиваемый ответ (дельта скролла от жестов — [GlassesApp.answerScrollDelta]).
|
||||
*/
|
||||
@Composable
|
||||
fun AssistantOverlay() {
|
||||
val app = GlassesApp.instance
|
||||
val listening by app.listening.collectAsState()
|
||||
val thinking by app.thinking.collectAsState()
|
||||
val sttText by app.sttText.collectAsState()
|
||||
val assistantText by app.assistantText.collectAsState()
|
||||
val state = AssistantState(listening, thinking, sttText, assistantText)
|
||||
if (!overlayVisible(state)) return
|
||||
overlayBox(app, state)
|
||||
}
|
||||
|
||||
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */
|
||||
@Composable
|
||||
private fun overlayBox(app: GlassesApp, state: AssistantState) {
|
||||
Binocular(scale = false) {
|
||||
val scale by app.scale.collectAsState()
|
||||
val screenWidthPx = BinocularScreenSize.current.width
|
||||
val screenHeightPx = BinocularScreenSize.current.height
|
||||
val side = BinocularScreenSide.current
|
||||
val containerW = (screenWidthPx * scale).toInt()
|
||||
val containerH = (screenHeightPx * scale).toInt()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(containerW.px)
|
||||
.height(containerH.px)
|
||||
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
|
||||
.background(Color.Black.copy(alpha = 0.72f)),
|
||||
) {
|
||||
AssistantOverlayContent(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantOverlayContent(state: AssistantState) {
|
||||
val app = GlassesApp.instance
|
||||
val hasAnswer = state.assistantText.isNotBlank()
|
||||
val header = overlayHeader(state)
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
// Новый ответ приходит с обнулением answerScrollDelta (ShowText) — к началу текста.
|
||||
LaunchedEffect(state.assistantText) {
|
||||
scrollState.scrollTo(0)
|
||||
}
|
||||
|
||||
// Накопленная дельта скролла от жестов: каждый глаз — свой [LaunchedEffect],
|
||||
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset].
|
||||
LaunchedEffect(Unit) {
|
||||
app.answerScrollDelta.collect { pending ->
|
||||
if (pending == 0f) return@collect
|
||||
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
|
||||
app.answerScrollDelta.value = 0f
|
||||
if (target != scrollState.value) {
|
||||
scrollState.animateScrollTo(target, tween(150))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = if (hasAnswer) Arrangement.Top else Arrangement.Center,
|
||||
) {
|
||||
if (!header.isNullOrBlank()) {
|
||||
Text(
|
||||
text = header,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
if (hasAnswer) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Text(
|
||||
text = state.assistantText,
|
||||
color = Color(0xFFB0BEC5),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package pw.binom.viewmate.glasses.gestures
|
||||
|
||||
import android.view.MotionEvent
|
||||
import com.rayneo.arsdk.android.touch.FlingArgs
|
||||
import com.rayneo.arsdk.android.touch.TempleAction
|
||||
import com.rayneo.arsdk.android.touch.TempleAction.Click
|
||||
import com.rayneo.arsdk.android.touch.TempleAction.DoubleClick
|
||||
import com.rayneo.arsdk.android.touch.TempleAction.SlideBackward
|
||||
import com.rayneo.arsdk.android.touch.TempleAction.SlideContinuous
|
||||
import com.rayneo.arsdk.android.touch.TempleAction.SlideForward
|
||||
import com.rayneo.arsdk.android.touch.TempleAction.TripleClick
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
|
||||
/**
|
||||
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино,
|
||||
* скролл ответа (дельта/зажим/усечение), заголовок оверлея («слушаю…/думаю…/фраза»).
|
||||
*/
|
||||
class AssistantGesturesTest {
|
||||
|
||||
private val movie = AssistantState()
|
||||
private val listening = AssistantState(listening = true)
|
||||
private val thinking = AssistantState(thinking = true)
|
||||
private val answer = AssistantState(assistantText = "ответ")
|
||||
|
||||
// ————— режим по состоянию —————
|
||||
|
||||
@Test
|
||||
fun movieStateIsNone() {
|
||||
assertEquals(AssistantMode.NONE, assistantMode(movie))
|
||||
}
|
||||
|
||||
/** Listening=true всё побеждает — жесты идут по матрице «слушаю». */
|
||||
@Test
|
||||
fun listeningWinsOverEverything() {
|
||||
val s = AssistantState(listening = true, thinking = true, sttText = "ф", assistantText = "а")
|
||||
assertEquals(AssistantMode.LISTENING, assistantMode(s))
|
||||
}
|
||||
|
||||
/** «Ответ виден» по ТЗ: !listening && assistantText непуст — даже при thinking. */
|
||||
@Test
|
||||
fun answerWinsOverThinking() {
|
||||
assertEquals(AssistantMode.ANSWER, assistantMode(AssistantState(thinking = true, assistantText = "а")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thinkingOnlyWhenNoAnswer() {
|
||||
assertEquals(AssistantMode.THINKING, assistantMode(thinking))
|
||||
}
|
||||
|
||||
// ————— кино ( NONE) —————
|
||||
|
||||
@Test
|
||||
fun movieMatrix() {
|
||||
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), movie))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(DoubleClick(), movie))
|
||||
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), movie))
|
||||
assertEquals(
|
||||
GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 30_000),
|
||||
gestureDecision(slideForward(), movie),
|
||||
)
|
||||
assertEquals(
|
||||
GestureDecision.Seek(MediaCommand.SEEK_BACKWARD, 30_000),
|
||||
gestureDecision(slideBackwardAction(), movie),
|
||||
)
|
||||
// Свайп «плавного» типа без ответа не управляет скроллом → игнор.
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), movie))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movieSeekStepIsParameter() {
|
||||
val d = gestureDecision(slideForward(), movie, seekStepMs = 10_000)
|
||||
assertEquals(GestureDecision.Seek(MediaCommand.SEEK_FORWARD, 10_000), d)
|
||||
}
|
||||
|
||||
// ————— слушаю —————
|
||||
|
||||
@Test
|
||||
fun listeningMatrix() {
|
||||
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), listening))
|
||||
assertEquals(GestureDecision.CancelDictation, gestureDecision(DoubleClick(), listening))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), listening))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), listening))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideBackwardAction(), listening))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), listening))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), listening))
|
||||
}
|
||||
|
||||
// ————— «думаю» (фраза отправлена, ответа ещё нет) —————
|
||||
|
||||
@Test
|
||||
fun thinkingMatrix() {
|
||||
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), thinking))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), thinking))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), thinking))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideForward(), thinking))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(slideContinuous(), thinking))
|
||||
}
|
||||
|
||||
// ————— «ответ виден» —————
|
||||
|
||||
@Test
|
||||
fun answerMatrix() {
|
||||
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), answer))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), answer))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(TripleClick(), answer))
|
||||
assertEquals(GestureDecision.ScrollAnswer(200f), gestureDecision(slideForward(), answer))
|
||||
assertEquals(GestureDecision.ScrollAnswer(-200f), gestureDecision(slideBackwardAction(), answer))
|
||||
// Плавный: накопленная дельта шлейфа, знак — как пришёл.
|
||||
assertEquals(GestureDecision.ScrollAnswer(12.5f), gestureDecision(slideContinuous(12.5f), answer))
|
||||
assertEquals(GestureDecision.ScrollAnswer(-7f), gestureDecision(slideContinuous(-7f), answer))
|
||||
assertEquals(GestureDecision.Ignore, gestureDecision(TempleAction.LongClick(), answer))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun answerPageDeltaIsParameter() {
|
||||
assertEquals(
|
||||
GestureDecision.ScrollAnswer(80f),
|
||||
gestureDecision(slideForward(), answer, pageDeltaPx = 80f),
|
||||
)
|
||||
}
|
||||
|
||||
// ————— сессия: кино → слушаю → «думаю» → ответ → выход —————
|
||||
|
||||
@Test
|
||||
fun sessionTransitions() {
|
||||
var s = movie
|
||||
assertEquals(GestureDecision.CallAssistant, gestureDecision(TripleClick(), s))
|
||||
s = s.copy(listening = true)
|
||||
assertEquals(GestureDecision.SendPhrase, gestureDecision(Click(), s))
|
||||
s = s.copy(listening = false, thinking = true) // фраза отправлена, ответ ещё нет
|
||||
assertEquals(AssistantMode.THINKING, assistantMode(s))
|
||||
assertEquals(GestureDecision.NewPhrase, gestureDecision(Click(), s))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
|
||||
s = s.copy(thinking = false, assistantText = "ответ")
|
||||
assertEquals(AssistantMode.ANSWER, assistantMode(s))
|
||||
assertEquals(GestureDecision.ExitMode, gestureDecision(DoubleClick(), s))
|
||||
// Выход: оверлей невидим, кино снова управляется кликом.
|
||||
val exited = s.copy(assistantText = "")
|
||||
assertFalse(overlayVisible(exited))
|
||||
assertEquals(GestureDecision.TogglePlayback, gestureDecision(Click(), exited))
|
||||
}
|
||||
|
||||
/** DoubleClick в «слушаю» — отмена: остаёмся в разговоре (выхода нет). */
|
||||
@Test
|
||||
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))
|
||||
}
|
||||
|
||||
// ————— оверлей —————
|
||||
|
||||
@Test
|
||||
fun overlayInvisibleInMovie() {
|
||||
// Настоящее кино-состояние: флаги общения не стоят (выход из режима их сбрасывает).
|
||||
assertFalse(overlayVisible(movie))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlayVisibleInListeningThinkingAndAnswer() {
|
||||
assertTrue(overlayVisible(listening))
|
||||
assertTrue(overlayVisible(thinking))
|
||||
assertTrue(overlayVisible(answer))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thinkingHeaderIsDotDotDot() {
|
||||
assertEquals("думаю...", overlayHeader(thinking))
|
||||
// Микрофон закрыт, но listening ещё true (после отправки фразы) — тоже «думаю...».
|
||||
assertEquals(
|
||||
"думаю...",
|
||||
overlayHeader(AssistantState(listening = true, thinking = true)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listeningHeaderFallsBackToListening() {
|
||||
assertEquals("слушаю...", overlayHeader(listening))
|
||||
assertEquals("привет", overlayHeader(listening.copy(sttText = "привет")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun answerHeaderIsLastPhraseOrNull() {
|
||||
assertEquals("привет", overlayHeader(answer.copy(sttText = "привет")))
|
||||
assertNull(overlayHeader(answer))
|
||||
assertNull(overlayHeader(movie))
|
||||
}
|
||||
|
||||
// ————— скролл ответа —————
|
||||
|
||||
@Test
|
||||
fun scrollOffsetAppliesDelta() {
|
||||
assertEquals(342, scrollOffset(132, 210f, 10_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollOffsetClampsAboveMax() {
|
||||
assertEquals(1000, scrollOffset(800, 1000f, 1000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollOffsetClampsBelowZero() {
|
||||
assertEquals(0, scrollOffset(100, -500f, 1000))
|
||||
}
|
||||
|
||||
/**
|
||||
* Float-дельта округляется вниз (к целой части), а не к ближайшему.
|
||||
* (10.7f в IEEE754 ~ 10.700000763, поэтому 100 - 10.7f = 89.2999… → 89.)
|
||||
*/
|
||||
@Test
|
||||
fun scrollOffsetTruncatesFractionalDelta() {
|
||||
assertEquals(112, scrollOffset(100, 12.7f, 10_000))
|
||||
assertEquals(89, scrollOffset(100, -10.7f, 10_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollOffsetWithZeroMax() {
|
||||
assertEquals(0, scrollOffset(5, 100f, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK-жесты в юнит-тестах: MotionEvent создаётся через sun.misc.Unsafe
|
||||
* (allocateInstance, без конструктора — в JVM-тестах Android-классы «not mocked»).
|
||||
* Как в GestureCommandsTest.
|
||||
*/
|
||||
private fun fakeMotionEvent(): MotionEvent {
|
||||
val unsafeClass = Class.forName("sun.misc.Unsafe")
|
||||
val theUnsafe = unsafeClass.getDeclaredField("theUnsafe")
|
||||
theUnsafe.isAccessible = true
|
||||
val unsafe = theUnsafe.get(null)
|
||||
val allocate = unsafeClass.getMethod("allocateInstance", Class::class.java)
|
||||
return allocate.invoke(unsafe, MotionEvent::class.java) as MotionEvent
|
||||
}
|
||||
|
||||
private fun unsafeFlingArgs(): FlingArgs {
|
||||
val me1 = fakeMotionEvent()
|
||||
val me2 = fakeMotionEvent()
|
||||
return FlingArgs(me1, me2, 1f, 1f)
|
||||
}
|
||||
|
||||
private fun slideForward() = SlideForward(unsafeFlingArgs())
|
||||
|
||||
private fun slideBackwardAction() = SlideBackward(unsafeFlingArgs())
|
||||
|
||||
/** SlideContinuous: дельта шлейфа; second — флаг long-жеста (на тест не влияет). */
|
||||
private fun slideContinuous(delta: Float = 9f) = SlideContinuous(delta, false)
|
||||
}
|
||||
Reference in New Issue
Block a user