diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt index 2644164..eb04ff3 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -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 diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt index f9c9670..d6eb7d1 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -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 -> { diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index 03de841..309450a 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -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) { - log("gesture", "DoubleClick → отмена распознавания") - sttMic.stop() - lifecycleScope.launch { - GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true)) - } - return + /** 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)) + } + } + + /** 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), 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 (корень, поверх любого экрана). } } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt new file mode 100644 index 0000000..364a12e --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGestures.kt @@ -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) diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt new file mode 100644 index 0000000..fd48a38 --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt @@ -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, + ) + } + } + } +} diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt new file mode 100644 index 0000000..508af2e --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/gestures/AssistantGesturesTest.kt @@ -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) +}