From 28d1c0206249fe20110ce52919578db626beda77 Mon Sep 17 00:00:00 2001 From: subochev Date: Thu, 1 Oct 2026 17:50:22 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B8:=20HUD=20(=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D1=8F+=D0=B7=D0=B0=D1=80=D1=8F=D0=B4)=20=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=85=D1=83=20=D0=BF=D0=BE=20=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D1=83=20=D0=BE=D0=B1=D0=BE=D0=B8=D1=85=20?= =?UTF-8?q?=D0=B3=D0=BB=D0=B0=D0=B7,=20=D0=B2=D1=81=D0=B5=D0=B3=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=B2=D0=B5=D1=80=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Панель перенесена из view-mate (StatusPanel) как часть активити, без виджета: StatusHud + rememberStatusHudData в :app-glasses-vm/ui. Слой добавляется в GlassesScreen поверх when(state) внутри Binocular, поэтому виден в любом состоянии — ожидание/нет связи/запущенный гость — и дублируется в обе половины (левый и правый глаз). --- .../rayneovm/glasses/ui/GlassesScreen.kt | 12 ++ .../pw/binom/rayneovm/glasses/ui/StatusHud.kt | 125 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/StatusHud.kt diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt index 69b1b60..8d9da39 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt @@ -46,6 +46,7 @@ fun GlassesScreen( val state by controller.state.collectAsState() val controllerFrameSource by controller.frameSource.collectAsState() val cursor by controller.cursor.collectAsState() + val hud = rememberStatusHudData() val noOp = remember { NoOpFrameSource() } val frameSource = controllerFrameSource ?: noOp @@ -95,6 +96,17 @@ fun GlassesScreen( ) } } + + // HUD (время + заряд) — всегда поверх, в обеих половинах (на обоих глазах), + // сверху по центру. Виден в любом состоянии: и в ожидании связи, и поверх гостя. + Binocular { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + StatusHud(hud, modifier = Modifier.padding(top = 6.dp)) + } + } } } diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/StatusHud.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/StatusHud.kt new file mode 100644 index 0000000..d126241 --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/StatusHud.kt @@ -0,0 +1,125 @@ +package pw.binom.rayneovm.glasses.ui + +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** Данные HUD: время + заряд + цвет индикатора заряда. */ +data class StatusHudData( + val time: String, + val percent: Int, + val chargeColorArgb: Int, +) + +private const val CHARGE_COLOR_CHARGING = 0xFF4CAF50.toInt() // зелёный — идёт заряд +private const val CHARGE_COLOR_DISCHARGING = 0xFFFFFFFF.toInt() // белый — разряд +private const val CHARGE_COLOR_PLUGGED_NOT_CHARGING = 0xFFE53935.toInt() // красный — подключён, но не заряжается + +/** Цвет индикатора заряда: не подключён=белый, CHARGING/FULL=зелёный, иначе красный. */ +private fun chargeColorArgb(plugged: Boolean, status: Int): Int = when { + !plugged -> CHARGE_COLOR_DISCHARGING + status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL -> CHARGE_COLOR_CHARGING + else -> CHARGE_COLOR_PLUGGED_NOT_CHARGING +} + +/** + * HUD очков: время + заряд, компактной «пилюлей» на полупрозрачном тёмном фоне. + * + * Панель вызывается внутри [pw.binom.mercury.binocular.Binocular] (см. `GlassesScreen`), + * поэтому автоматически дублируется в обеих половинах — левом и правом глазу. + */ +@Composable +fun StatusHud(data: StatusHudData, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background(Color(0x99000000)) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(data.time, color = Color.White, fontSize = 14.sp) + Spacer(Modifier.width(6.dp)) + // Процент — цветом состояния заряда, без отдельного квадратика. + Text("${data.percent}%", color = Color(data.chargeColorArgb), fontSize = 14.sp) + } +} + +/** + * Провайдер данных HUD: время обновляется раз в секунду, заряд — из sticky + * `ACTION_BATTERY_CHANGED`. Держится один на весь экран; обе половины читают одни данные. + */ +@Composable +fun rememberStatusHudData(): StatusHudData { + val context = LocalContext.current + var data by remember { + mutableStateOf( + StatusHudData( + time = "--:--", + percent = 0, + chargeColorArgb = CHARGE_COLOR_DISCHARGING, + ) + ) + } + DisposableEffect(Unit) { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + var percent = 0 + var plugged = false + var status = BatteryManager.BATTERY_STATUS_UNKNOWN + + fun readBattery() { + val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + ?: return + val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) + status = intent.getIntExtra( + BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN + ) + plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0 + percent = if (scale > 0 && level >= 0) { + (level * 100f / scale).roundToInt().coerceIn(0, 100) + } else { + level.coerceIn(0, 100) + } + } + + scope.launch { + while (isActive) { + readBattery() + data = StatusHudData(timeFormat.format(Date()), percent, chargeColorArgb(plugged, status)) + delay(1_000) + } + } + onDispose { scope.cancel() } + } + return data +}