Очки: жесты и режимы общения с ассистентом (по 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); остаётся до новой сессии. */
|
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
|
||||||
val assistantText = MutableStateFlow("")
|
val assistantText = MutableStateFlow("")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
|
||||||
|
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
|
||||||
|
* до первого ShowText оверлей показывает «думаю...» вместо «слушаю...».
|
||||||
|
*/
|
||||||
|
val thinking = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Непоглощённая дельта скролла текста ответа (px): положительная — к концу текста.
|
||||||
|
* Накапливается от жестов (SlideContinuous/дискретные свайпы, режим «ответ виден»),
|
||||||
|
* потребляется оверлеем (AssistantOverlay), зажим в [0, maxScroll] скролла.
|
||||||
|
*/
|
||||||
|
val answerScrollDelta = MutableStateFlow(0f)
|
||||||
|
|
||||||
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
/** Текущая Activity (для закрытия приложения по команде с телефона). */
|
||||||
var mainActivity: MainActivity? = null
|
var mainActivity: MainActivity? = null
|
||||||
|
|
||||||
|
|||||||
@@ -333,6 +333,8 @@ class HostConnection(
|
|||||||
|
|
||||||
is ShowText -> {
|
is ShowText -> {
|
||||||
addMessage("ShowText: ${msg.text}")
|
addMessage("ShowText: ${msg.text}")
|
||||||
|
GlassesApp.instance.thinking.value = false
|
||||||
|
GlassesApp.instance.answerScrollDelta.value = 0f
|
||||||
GlassesApp.instance.assistantText.value = msg.text
|
GlassesApp.instance.assistantText.value = msg.text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +353,7 @@ class HostConnection(
|
|||||||
is SttCancel -> {
|
is SttCancel -> {
|
||||||
log("stt", "отменено")
|
log("stt", "отменено")
|
||||||
GlassesApp.instance.listening.value = false
|
GlassesApp.instance.listening.value = false
|
||||||
|
GlassesApp.instance.thinking.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
is Shutdown -> {
|
is Shutdown -> {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import androidx.compose.foundation.background
|
|||||||
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
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -37,7 +36,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
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.MediaCommandMsg
|
||||||
import pw.binom.viewmate.core.protocol.SttAudio
|
import pw.binom.viewmate.core.protocol.SttAudio
|
||||||
import pw.binom.viewmate.core.protocol.StopStt
|
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.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.status.StatusPanelCompose
|
||||||
import pw.binom.viewmate.glasses.stt.SttMicStream
|
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.Binocular
|
||||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
||||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
|
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
|
||||||
@@ -80,9 +82,9 @@ class MainActivity : BaseEventActivity() {
|
|||||||
private val debugReceiver = object : BroadcastReceiver() {
|
private val debugReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
when (intent?.getStringExtra("type")) {
|
when (intent?.getStringExtra("type")) {
|
||||||
"triple_click" -> handleTripleClick()
|
"triple_click" -> onTempleAction(TempleAction.TripleClick())
|
||||||
"click" -> handleClick()
|
"click" -> onTempleAction(TempleAction.Click())
|
||||||
"double_click" -> handleDoubleClick()
|
"double_click" -> onTempleAction(TempleAction.DoubleClick())
|
||||||
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
|
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +139,9 @@ class MainActivity : BaseEventActivity() {
|
|||||||
} else {
|
} else {
|
||||||
ConnectionScreen()
|
ConnectionScreen()
|
||||||
}
|
}
|
||||||
|
// Оверлей режима общения: поверх любого экрана (затемняет кино),
|
||||||
|
// виден в «слушаю»/«думаю»/«ответ виден».
|
||||||
|
AssistantOverlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,11 +195,6 @@ class MainActivity : BaseEventActivity() {
|
|||||||
}, null)
|
}, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick —
|
|
||||||
* голосовой ввод (безусловный вызов), Click во время прослушивания —
|
|
||||||
* завершить распознавание, DoubleClick — отмена.
|
|
||||||
*/
|
|
||||||
private fun requestBtPermissions() {
|
private fun requestBtPermissions() {
|
||||||
if (!GlassesConfig.USE_BLUETOOTH) return
|
if (!GlassesConfig.USE_BLUETOOTH) return
|
||||||
val granted = BtGlassesTransport.runtimePermissions().all {
|
val granted = BtGlassesTransport.runtimePermissions().all {
|
||||||
@@ -205,73 +205,127 @@ class MainActivity : BaseEventActivity() {
|
|||||||
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
|
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** События трекпада (SDK) → [onTempleAction] — единственная точка входа жестов. */
|
||||||
private fun collectTempleActions() {
|
private fun collectTempleActions() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
templeActionViewModel.state.collect { action ->
|
templeActionViewModel.state.collect { action ->
|
||||||
when (action) {
|
onTempleAction(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)}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TripleClick: если не слушаем — пауза кино + старт распознавания. */
|
/**
|
||||||
private fun handleTripleClick() {
|
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
|
||||||
if (GlassesApp.instance.listening.value) {
|
* (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 проигнорирован: распознавание уже идёт")
|
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log("stt", "ассистент: слушаю")
|
log("stt", "ассистент: слушаю")
|
||||||
if (GlassesApp.instance.movieController.pauseIfPlaying()) {
|
if (app.movieController.pauseIfPlaying()) {
|
||||||
log("stt", "кино на паузе (TripleClick)")
|
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
||||||
}
|
}
|
||||||
GlassesApp.instance.listening.value = true
|
app.listening.value = true
|
||||||
GlassesApp.instance.sttText.value = ""
|
app.thinking.value = false
|
||||||
GlassesApp.instance.assistantText.value = ""
|
app.sttText.value = ""
|
||||||
sttMic.start(filesDir) { GlassesApp.instance.listening.value }
|
app.assistantText.value = ""
|
||||||
|
app.answerScrollDelta.value = 0f
|
||||||
|
sttMic.start(filesDir) { app.listening.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click: во время прослушивания — завершить распознавание; иначе пауза/плей. */
|
/** Click (кино): плей/пауза — как сегодня. */
|
||||||
private fun handleClick() {
|
private fun togglePlayback() {
|
||||||
if (GlassesApp.instance.listening.value) {
|
|
||||||
log("gesture", "Click → завершить распознавание")
|
|
||||||
sttMic.stop()
|
|
||||||
lifecycleScope.launch {
|
|
||||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
|
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
|
||||||
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
|
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
|
||||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DoubleClick: во время прослушивания — отмена; иначе — резерв. */
|
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
|
||||||
private fun handleDoubleClick() {
|
private fun sendPhrase() {
|
||||||
if (GlassesApp.instance.listening.value) {
|
log("gesture", "Click → отправить фразу")
|
||||||
log("gesture", "DoubleClick → отмена распознавания")
|
sttMic.stop()
|
||||||
sttMic.stop()
|
GlassesApp.instance.thinking.value = true
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
||||||
}
|
}
|
||||||
return
|
}
|
||||||
|
|
||||||
|
/** 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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()
|
||||||
}
|
}
|
||||||
log("gesture", "жест (резерв): DoubleClick")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -411,41 +465,7 @@ fun ConnectionScreen() {
|
|||||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
||||||
scale = scale,
|
scale = scale,
|
||||||
)
|
)
|
||||||
|
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
|
||||||
// Оверлей распознавания: затемнение + накопленный текст (поверх всего).
|
|
||||||
// Остаётся и после завершения распознавания, пока не стёрт ответ ассистента
|
|
||||||
// (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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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