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

- AssistantMode: NONE/LISTENING/THINKING/ANSWER + чистая gestureDecision (матрица жестов)
- Клик в «ответ виден» = новая фраза; DoubleClick = выход (кино на паузе); свайпы = скролл
- Плавный скролл ответа: SlideContinuous delta → answerScrollDelta → scrollOffset (зажим 0..max)
- Индикатор «думаю...» (thinking, сброс на ShowText)
- AssistantOverlay: затемнение 72%, по глазам, скролл-аккумулятор
- Тесты: 22 (AssistantGesturesTest) — glasses 91 зелёных
This commit is contained in:
2026-08-23 01:55:24 +03:00
parent f36d04adc1
commit f75e672819
6 changed files with 664 additions and 90 deletions
@@ -0,0 +1,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,
)
}
}
}
}