chore(cleanup): NIRS/VIVAMYO 세트 완전 제거 (Category A · 사용자 요청)

배경: NIRS 는 초기 iOS 앱에서 이식했으나 현재 완전 미사용. Piezo 만 실사용.

완전 제거 (6 파일):
  · managers/NirsManager.kt (94 L)
  · managers/NirsCalcEngine.kt (206 L)
  · models/NirsConstants.kt (49 L)
  · models/NirsData.kt (106 L · NirsMcjPacket · NirsMagResponse · NirsOxyResult)
  · ui/views/monitoring/VivamyoMonitoringView.kt (497 L)
  · ui/views/calibration/CalibrationView.kt (216 L) + calibration/ 디렉토리

부분 편집:
  · AppState.kt      — NirsManager import · NIRS_CALIBRATION/VIVAMYO_MONITORING enum ·
                       calibrationComplete() · sensor 분기 3곳 (goHome/enterDemoMode/
                       deviceConnected/backToMonitoring) 모두 PIEZO 로 단순화
  · MainActivity.kt  — CalibrationView/VivamyoMonitoringView import + Crossfade branch +
                       back-handler branch 삭제
  · BleManager.kt    — 콜백 4개 (onNirsPowerOn/SensorActivated/MagReceived/McjReceived) ·
                       sendRawWrite when-branch 4개 · NIRS Commands 섹션 5 함수 ·
                       RX branch 4개 삭제
  · MeasurementService.kt — performNirsMeasurement 제거 · performMeasurement 단순화 ·
                       stop() 콜백 정리 · SensorMode 유지 (센서 추가 대비)
  · SensorMode.kt    — NIRS enum value 삭제 · PIEZO 만 남음 · UserStorage 는
                       valueOf 실패 시 PIEZO fallback 이라 마이그레이션 불필요
  · SensorSelectView.kt — NIRS SensorCard 제거
  · strings.xml × 2  — sensor_nirs_desc · calibration_* 10개 · vivamyo_* 17개 삭제
                       (calibration_continue 는 PiezoPersonalization 사용 → 유지)

합계: 약 1,300 LOC 감소.
빌드: BUILD SUCCESSFUL 17s.
This commit is contained in:
2026-08-12 15:38:59 +09:00
parent 0fe0687041
commit 1ce19949c9
14 changed files with 9 additions and 1325 deletions
@@ -6,15 +6,14 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.managers.NirsManager
import com.medithings.vesiscan.models.*
import com.medithings.vesiscan.services.*
enum class AppScreen {
ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN,
NIRS_CALIBRATION, PIEZO_PERSONALIZATION,
PIEZO_PERSONALIZATION,
PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
PIEZO_MONITORING, MEASUREMENT_HISTORY,
VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS,
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
@@ -93,8 +92,6 @@ class AppState(private val context: Context) {
}
fun goHome() {
NirsManager.instance.stopStreaming()
NirsManager.instance.reset()
// 회의 결정: 실제 BLE 연결은 HOME에서도 유지. 다음 Start 시 설정/scan skip하고
// 바로 PiezoMonitoring으로 진입할 수 있도록.
// Demo 모드는 가짜 연결이므로 isDeviceConnected reset.
@@ -106,11 +103,7 @@ class AppState(private val context: Context) {
fun enterDemoMode() {
isDemoMode = true
isDeviceConnected = true
currentScreen = if (sensorMode == SensorMode.NIRS) {
AppScreen.VIVAMYO_MONITORING
} else {
AppScreen.PIEZO_MONITORING
}
currentScreen = AppScreen.PIEZO_MONITORING
}
fun startFromHome() {
@@ -138,15 +131,7 @@ class AppState(private val context: Context) {
currentScreen = AppScreen.CLINICAL_HOME
return
}
currentScreen = if (sensorMode == SensorMode.NIRS) {
AppScreen.NIRS_CALIBRATION
} else {
AppScreen.PIEZO_PERSONALIZATION
}
}
fun calibrationComplete() {
currentScreen = AppScreen.VIVAMYO_MONITORING
currentScreen = AppScreen.PIEZO_PERSONALIZATION
}
fun personalizationComplete() {
@@ -299,7 +284,7 @@ class AppState(private val context: Context) {
}
fun backToMonitoring() {
currentScreen = if (sensorMode == SensorMode.NIRS) AppScreen.VIVAMYO_MONITORING else AppScreen.PIEZO_MONITORING
currentScreen = AppScreen.PIEZO_MONITORING
}
fun addCatheterCount(amount: Int = 1) {
@@ -24,13 +24,11 @@ import com.medithings.vesiscan.services.KeychainService
import com.medithings.vesiscan.services.NotificationService
import com.medithings.vesiscan.R
import com.medithings.vesiscan.ui.theme.Medilightv2androidTheme
import com.medithings.vesiscan.ui.views.calibration.CalibrationView
import com.medithings.vesiscan.ui.views.connection.DeviceScanView
import com.medithings.vesiscan.ui.views.history.MeasurementHistoryView
import com.medithings.vesiscan.ui.views.home.HomeView
import com.medithings.vesiscan.ui.views.monitoring.PlacementGuideView
import com.medithings.vesiscan.ui.views.monitoring.PiezoMonitoringView
import com.medithings.vesiscan.ui.views.monitoring.VivamyoMonitoringView
import com.medithings.vesiscan.ui.views.monitoring.UrineCameraScreen
import com.medithings.vesiscan.ui.views.monitoring.VoidingDiaryView
import com.medithings.vesiscan.ui.views.onboarding.OnboardingView
@@ -155,8 +153,6 @@ fun MedilightApp(modifier: Modifier = Modifier) {
AppScreen.FIRMWARE_UPDATE -> appState.currentScreen = AppScreen.HOME
// 기타
AppScreen.SENSOR_SELECT -> appState.currentScreen = AppScreen.HOME
AppScreen.VIVAMYO_MONITORING -> appState.currentScreen = AppScreen.HOME
AppScreen.NIRS_CALIBRATION -> appState.currentScreen = AppScreen.DEVICE_SCAN
// 온보딩/등록 (뒤로가기 막지 않되 Home으로)
else -> appState.currentScreen = AppScreen.HOME
}
@@ -175,12 +171,10 @@ fun MedilightApp(modifier: Modifier = Modifier) {
AppScreen.HOME -> HomeView(appState)
AppScreen.SENSOR_SELECT -> SensorSelectView(appState)
AppScreen.DEVICE_SCAN -> DeviceScanView(appState)
AppScreen.NIRS_CALIBRATION -> CalibrationView(appState)
AppScreen.PIEZO_PERSONALIZATION -> PiezoPersonalizationView(appState)
AppScreen.PLACEMENT_GUIDE -> PlacementGuideView(appState)
AppScreen.PIN_SETUP -> PinView(appState, PinMode.SETUP, context)
AppScreen.PIN_ENTRY -> PinView(appState, PinMode.ENTER, context)
AppScreen.VIVAMYO_MONITORING -> VivamyoMonitoringView(appState)
AppScreen.PIEZO_MONITORING -> PiezoMonitoringView(appState)
AppScreen.MEASUREMENT_HISTORY -> MeasurementHistoryView(appState)
AppScreen.VOIDING_DIARY -> VoidingDiaryView(onBack = { appState.currentScreen = AppScreen.HOME })
@@ -287,10 +287,6 @@ class BleManager private constructor(private val context: Context) {
// Callbacks
var onConnectionStateChanged: ((Boolean) -> Unit)? = null
var onNirsPowerOnReceived: (() -> Unit)? = null
var onNirsSensorActivated: (() -> Unit)? = null
var onNirsMagReceived: ((ByteArray) -> Unit)? = null
var onNirsMcjReceived: ((ByteArray) -> Unit)? = null
var onPiezoDataReceived: ((ByteArray) -> Unit)? = null
var onUnexpectedDisconnect: (() -> Unit)? = null
var onReconnectionFailed: (() -> Unit)? = null
@@ -717,10 +713,6 @@ class BleManager private constructor(private val context: Context) {
"mps" -> "piezo stop"
"mec" -> "single burst"
"msn" -> "battery query"
"mta" -> if (data.size > 4 && data[4].toInt() == 1) "NIRS power ON" else "NIRS power OFF"
"mqq" -> "NIRS activate"
"mag" -> "NIRS gain query"
"mcj" -> "NIRS MCJ query"
"mid" -> "device info query"
// mls 는 buildCommandBE 로 16bit BE 인코딩 → data[4]=High, data[5]=Low
"mls" -> "LED mode ${
@@ -831,28 +823,6 @@ class BleManager private constructor(private val context: Context) {
return true
}
// ── NIRS Commands ──
fun sendNirsPowerOn() {
sendRaw(CRC16.buildCommandBE("mta", intArrayOf(1)))
}
fun sendNirsPowerOff() {
sendRaw(CRC16.buildCommandBE("mta", intArrayOf(0)))
}
fun sendMqqQuery() {
sendRaw(CRC16.buildCommandASCII("mqq", " "))
}
fun sendMagQuery() {
sendRaw(CRC16.buildCommandASCII("mag", " "))
}
fun sendMcjQuery() {
sendRaw(CRC16.buildCommandASCII("mcj", " "))
}
// ── Common Commands ──
fun sendBatteryQuery() {
@@ -1644,22 +1614,6 @@ class BleManager private constructor(private val context: Context) {
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
logd { "Received: $prefix (${data.size} bytes)" }
when (prefix) {
"rta:", "rta!" -> {
debugLogger.rx("rta", data.size, "piezo power ON")
onNirsPowerOnReceived?.invoke()
}
"rsh:", "rsh!", "rqq:", "rqq!" -> {
debugLogger.rx(prefix.take(3), data.size, "sensor activated")
onNirsSensorActivated?.invoke()
}
"rag:", "rag!" -> {
debugLogger.rx("rag", data.size, "gain calibration (${(data.size - 6) / 2} values)")
onNirsMagReceived?.invoke(data)
}
"rcj:", "rcj!" -> {
debugLogger.rx("rcj", data.size, "MCJ packet")
onNirsMcjReceived?.invoke(data)
}
"rpa:" -> {
debugLogger.rx("rpa", data.size, "piezo ack")
onPiezoDataReceived?.invoke(data)
@@ -1,206 +0,0 @@
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.models.NirsConstants
import com.medithings.vesiscan.models.NirsMagResponse
import com.medithings.vesiscan.models.NirsOxyResult
import kotlin.math.abs
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.pow
class NirsCalcEngine {
// Gain Calibration
private var gainValues: IntArray = intArrayOf()
val hasGain: Boolean get() = gainValues.size >= 24
fun setGainFromMag(response: NirsMagResponse) {
gainValues = response.gainValues
}
fun clearGain() {
gainValues = intArrayOf()
}
// AGC DAC -> Software Amplification
private fun agcGain(dacValue: Int): Double {
return 10.0.pow(((dacValue.toDouble() * 3.3 / 4096.0 * -80.0) + 88.0) / 20.0)
}
// DeGain Calculation
fun degain(rawLed: IntArray): DoubleArray {
if (rawLed.size < 24 || !hasGain) return DoubleArray(24) { 0.0 }
val result = DoubleArray(24)
for (i in 0 until 24) {
val raw = rawLed[i].toDouble()
val sag = gainValues[i]
val pkg = i / 6
val wl = i % 6
val softAmp = agcGain(sag)
val hAmp = NirsConstants.hardwareAmp[pkg]
val spResponse = NirsConstants.spectralResponse[wl]
val dpf = NirsConstants.dpf[wl]
val denominator = softAmp * hAmp
if (denominator > 0) {
var dg = raw / denominator
dg /= spResponse
dg *= dpf
result[i] = dg
}
}
return result
}
// Butterworth IIR Filter
private var filterState: Array<DoubleArray> = emptyArray()
private var filterInitialized = false
var isFilterEnabled = true
fun resetFilter() {
filterState = Array(24) { doubleArrayOf(0.0, 0.0, 0.0, 0.0) }
filterInitialized = false
}
private fun applyFilter(x: Double, state: DoubleArray): Double {
val b = NirsConstants.filterB
val a = NirsConstants.filterA
val y = b[0] * x + b[1] * state[0] + b[2] * state[1] - a[1] * state[2] - a[2] * state[3]
state[1] = state[0]
state[0] = x
state[3] = state[2]
state[2] = y
return y
}
fun filterDeGain(degainValues: DoubleArray): DoubleArray {
if (degainValues.size < 24) return degainValues
if (filterState.isEmpty()) resetFilter()
if (!filterInitialized) {
for (i in 0 until 24) {
val v = degainValues[i]
filterState[i] = doubleArrayOf(v, v, v, v)
}
filterInitialized = true
}
val filtered = DoubleArray(24)
for (i in 0 until 24) {
filtered[i] = applyFilter(degainValues[i], filterState[i])
}
return filtered
}
// Oxygenation Calculation
fun calcOxygenation(degainValues: DoubleArray): NirsOxyResult {
if (degainValues.size < 24) return NirsOxyResult.zero
val c = NirsConstants
val muA = DoubleArray(c.numWavelengths)
for (i in 0 until c.numWavelengths) {
val i2cm = degainValues[i]
val i5cm = degainValues[18 + i]
val i2norm = i2cm / (c.ledPower[0] / c.referencePower)
val i5norm = i5cm / (c.ledPower[3] / c.referencePower)
val mix = c.srsMixNear * i2norm + c.srsMixFar * i5norm
if (mix > 0) {
val slope = -ln(mix) / c.srsPathLength
muA[i] = -slope
}
}
val solution = solveNormalEq6x3(muA)
var hbO2 = max(0.0, solution.first) * c.oxyScale
var hb = max(0.0, solution.second) * c.oxyScale
val tHb = hbO2 + hb
val stO2 = if (tHb > 0) (hbO2 / tHb) * 100.0 else 0.0
return NirsOxyResult(hbO2 = hbO2, hb = hb, tHb = tHb, stO2 = stO2)
}
// Least Squares Solver (6x3)
private fun solveNormalEq6x3(muA: DoubleArray): Triple<Double, Double, Double> {
val eps = arrayOf(
NirsConstants.extinctionHbO2,
NirsConstants.extinctionHb,
NirsConstants.extinctionH2O
)
val n = Array(3) { DoubleArray(3) }
for (i in 0 until 3) {
for (j in 0 until 3) {
var sum = 0.0
for (k in 0 until 6) sum += eps[i][k] * eps[j][k]
n[i][j] = sum
}
}
val b = DoubleArray(3)
for (i in 0 until 3) {
var sum = 0.0
for (k in 0 until 6) sum += eps[i][k] * muA[k]
b[i] = sum
}
val det = n[0][0] * (n[1][1] * n[2][2] - n[1][2] * n[2][1]) -
n[0][1] * (n[1][0] * n[2][2] - n[1][2] * n[2][0]) +
n[0][2] * (n[1][0] * n[2][1] - n[1][1] * n[2][0])
if (abs(det) < 1e-12) return Triple(0.0, 0.0, 0.0)
val inv = Array(3) { DoubleArray(3) }
inv[0][0] = (n[1][1] * n[2][2] - n[1][2] * n[2][1]) / det
inv[0][1] = -(n[0][1] * n[2][2] - n[0][2] * n[2][1]) / det
inv[0][2] = (n[0][1] * n[1][2] - n[0][2] * n[1][1]) / det
inv[1][0] = -(n[1][0] * n[2][2] - n[1][2] * n[2][0]) / det
inv[1][1] = (n[0][0] * n[2][2] - n[0][2] * n[2][0]) / det
inv[1][2] = -(n[0][0] * n[1][2] - n[0][2] * n[1][0]) / det
inv[2][0] = (n[1][0] * n[2][1] - n[1][1] * n[2][0]) / det
inv[2][1] = -(n[0][0] * n[2][1] - n[0][1] * n[2][0]) / det
inv[2][2] = (n[0][0] * n[1][1] - n[0][1] * n[1][0]) / det
val c = DoubleArray(3) { i ->
inv[i][0] * b[0] + inv[i][1] * b[1] + inv[i][2] * b[2]
}
return Triple(c[0], c[1], c[2])
}
// Full Processing Pipeline
data class ProcessResult(
val degain: DoubleArray,
val degainFiltered: DoubleArray,
val oxy: NirsOxyResult,
val oxyFiltered: NirsOxyResult
)
fun process(rawLed: IntArray): ProcessResult {
val dg = degain(rawLed)
val oxyRaw = calcOxygenation(dg)
val dgf: DoubleArray
val oxyF: NirsOxyResult
if (isFilterEnabled) {
dgf = filterDeGain(dg)
oxyF = calcOxygenation(dgf)
} else {
dgf = dg
oxyF = oxyRaw
}
return ProcessResult(dg, dgf, oxyRaw, oxyF)
}
}
@@ -1,94 +0,0 @@
package com.medithings.vesiscan.managers
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import com.medithings.vesiscan.models.NirsMagResponse
import com.medithings.vesiscan.models.NirsMcjPacket
import com.medithings.vesiscan.models.NirsConstants
import com.medithings.vesiscan.BuildConfig
import com.medithings.vesiscan.models.NirsOxyResult
class NirsManager private constructor() {
companion object {
val instance: NirsManager by lazy { NirsManager() }
}
// State
val isStreaming = mutableStateOf(false)
val isCalibrated = mutableStateOf(false)
val isFilterEnabled = mutableStateOf(true)
// Current Values
val currentOxy = mutableStateOf(NirsOxyResult.zero)
val sampleCount = mutableIntStateOf(0)
// Chart History
val oxyHbO2History = mutableStateListOf<Double>()
val oxyHbHistory = mutableStateListOf<Double>()
// Internal
val calcEngine = NirsCalcEngine()
private val maxPoints = NirsConstants.maxHistoryPoints
// Calibration
fun setCalibrationGain(response: NirsMagResponse) {
calcEngine.setGainFromMag(response)
isCalibrated.value = true
}
// Process mcj Packet
fun processMcjPacket(packet: NirsMcjPacket) {
val result = calcEngine.process(packet.ledValues)
currentOxy.value = result.oxyFiltered
sampleCount.intValue++
if (BuildConfig.DEBUG && (sampleCount.intValue <= 3 || sampleCount.intValue % 50 == 0)) {
android.util.Log.d("NirsManager", "Sample #${sampleCount.intValue}: " +
"hasGain=${calcEngine.hasGain}, " +
"led[0..5]=${packet.ledValues.take(6)}, " +
"degain[0..2]=${result.degain.take(3).map { "%.2f".format(it) }}, " +
"oxy=StO2:${"%.1f".format(result.oxyFiltered.stO2)} HbO2:${"%.1f".format(result.oxyFiltered.hbO2)} HHb:${"%.1f".format(result.oxyFiltered.hb)}")
}
appendTrimming(oxyHbO2History, result.oxyFiltered.hbO2)
appendTrimming(oxyHbHistory, result.oxyFiltered.hb)
}
// Controls
fun toggleFilter() {
isFilterEnabled.value = !isFilterEnabled.value
calcEngine.isFilterEnabled = isFilterEnabled.value
if (isFilterEnabled.value) calcEngine.resetFilter()
}
fun clearData() {
oxyHbO2History.clear()
oxyHbHistory.clear()
currentOxy.value = NirsOxyResult.zero
sampleCount.intValue = 0
calcEngine.resetFilter()
}
fun startStreaming() {
isStreaming.value = true
calcEngine.resetFilter()
}
fun stopStreaming() {
isStreaming.value = false
}
fun reset() {
stopStreaming()
clearData()
isCalibrated.value = false
calcEngine.clearGain()
}
// Helpers
private fun appendTrimming(list: MutableList<Double>, value: Double) {
list.add(value)
while (list.size > maxPoints) list.removeAt(0)
}
}
@@ -1,49 +0,0 @@
package com.medithings.vesiscan.models
import androidx.compose.ui.graphics.Color
object NirsConstants {
// Hardware Parameters
val hardwareAmp = doubleArrayOf(1.2, 5.008, 11.22, 37.072)
val ledPower = doubleArrayOf(5.0, 10.0, 15.0, 20.0)
val spectralResponse = doubleArrayOf(0.457, 0.561, 0.602, 0.653, 0.71, 0.725)
val dpf = doubleArrayOf(7.0, 5.8, 5.5, 5.2, 4.9, 4.7)
// Extinction Coefficients
val extinctionHbO2 = doubleArrayOf(0.303, 0.586, 0.816, 1.058, 1.224, 1.162)
val extinctionHb = doubleArrayOf(2.968, 1.549, 0.762, 0.691, 0.777, 0.429)
val extinctionH2O = doubleArrayOf(0.003, 0.026, 0.020, 0.044, 0.267, 0.448)
// Butterworth IIR Filter (2nd order, Fc=1.5Hz, Fs=4Hz)
val filterB = doubleArrayOf(0.4208, 0.8416, 0.4208)
val filterA = doubleArrayOf(1.0, 0.3533, 0.3300)
// SRS Parameters
const val srsMixNear = 0.8
const val srsMixFar = 0.2
const val srsPathLength = 2.6
const val referencePower = 20.0
const val oxyScale = 22.0
// Dimensions
const val numPackages = 4
const val numWavelengths = 6
const val numChannels = numPackages * numWavelengths
// Wavelength Definitions
val wavelengths = intArrayOf(665, 760, 800, 850, 920, 970)
val wavelengthNames = arrayOf("665nm", "760nm", "800nm", "850nm", "920nm", "970nm")
val wavelengthColors = listOf(
Color.Red,
Color.Blue,
Color(0xFF33CC33),
Color(0xFFFF9500),
Color(0xFF9966FF),
Color(0xFF666666)
)
// Chart History
const val maxHistoryPoints = 240
const val streamingIntervalMs = 250L
}
@@ -1,106 +0,0 @@
package com.medithings.vesiscan.models
data class NirsMcjPacket(
val battery: Int,
val temperature: Int,
val accX: Short,
val accY: Short,
val accZ: Short,
val gyrX: Short,
val gyrY: Short,
val gyrZ: Short,
val ledValues: IntArray
) {
companion object {
private const val TAG = "NirsMcjPacket"
fun parse(data: ByteArray): NirsMcjPacket? {
if (data.size < 72) {
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
android.util.Log.e(TAG, "parse: data too small: ${data.size} < 72")
}
return null
}
fun u16(offset: Int): Int =
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
fun s16(offset: Int): Short =
u16(offset).toShort()
val base = 4
val battery = u16(base)
val temperature = u16(base + 2)
val accX = s16(base + 4)
val accY = s16(base + 6)
val accZ = s16(base + 8)
val gyrX = s16(base + 10)
val gyrY = s16(base + 12)
val gyrZ = s16(base + 14)
val ledValues = IntArray(24)
for (i in 0 until 24) {
val offset = base + 20 + (i * 2)
if (offset + 1 >= data.size) {
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
android.util.Log.e(TAG, "parse: LED[$i] out of bounds: offset=${offset+1} >= size=${data.size}")
}
return null
}
ledValues[i] = u16(offset)
}
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
android.util.Log.d(TAG, "parse OK: battery=$battery led[0..5]=${ledValues.take(6)}")
}
return NirsMcjPacket(
battery, temperature,
accX, accY, accZ,
gyrX, gyrY, gyrZ,
ledValues
)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is NirsMcjPacket) return false
return battery == other.battery && ledValues.contentEquals(other.ledValues)
}
override fun hashCode(): Int = battery * 31 + ledValues.contentHashCode()
}
data class NirsMagResponse(val gainValues: IntArray) {
companion object {
fun parse(data: ByteArray): NirsMagResponse? {
if (data.size < 52) return null
val base = 4
val values = IntArray(24) { i ->
val offset = base + (i * 2)
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
}
return NirsMagResponse(values)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is NirsMagResponse) return false
return gainValues.contentEquals(other.gainValues)
}
override fun hashCode(): Int = gainValues.contentHashCode()
}
data class NirsOxyResult(
val hbO2: Double = 0.0,
val hb: Double = 0.0,
val tHb: Double = 0.0,
val stO2: Double = 0.0
) {
companion object {
val zero = NirsOxyResult()
}
}
@@ -1,6 +1,6 @@
package com.medithings.vesiscan.models
// 2026-08-12: NIRS 제거. 향후 다른 센서 추가 대비해 enum 유지.
enum class SensorMode(val displayName: String) {
NIRS("NIRS"),
PIEZO("Piezo")
}
@@ -19,25 +19,11 @@ class MeasurementService private constructor(context: Context) {
val isMeasuring = mutableStateOf(false)
// 2026-08-12: NIRS 제거 후 PIEZO 만 남음. 시그니처는 향후 센서 추가 대비 유지.
@Suppress("UNUSED_PARAMETER")
fun performMeasurement(mode: SensorMode, completion: (Boolean) -> Unit) {
isMeasuring.value = true
when (mode) {
SensorMode.NIRS -> performNirsMeasurement(completion)
SensorMode.PIEZO -> performPiezoMeasurement(completion)
}
}
// 2026-07-07 fix: MeasurementService 는 singleton. 콜백을 대입만 하고 정리하지 않으면
// BleManager 에 stale closure 가 계속 살아있어 이후 응답 이벤트가 이미 완료된
// measurement session 의 completion() 을 다시 호출. Self-clearing lambda 로 처리.
private fun performNirsMeasurement(completion: (Boolean) -> Unit) {
bleManager.onNirsMcjReceived = null // 이전 stale 콜백 해제
bleManager.onNirsMcjReceived = { _ ->
bleManager.onNirsMcjReceived = null // 응답 1회 처리 후 자동 해제
isMeasuring.value = false
completion(true)
}
bleManager.sendMcjQuery()
performPiezoMeasurement(completion)
}
private fun performPiezoMeasurement(completion: (Boolean) -> Unit) {
@@ -52,8 +38,6 @@ class MeasurementService private constructor(context: Context) {
fun stop() {
isMeasuring.value = false
// 대기 중이던 콜백 취소.
bleManager.onNirsMcjReceived = null
bleManager.onPiezoDataReceived = null
bleManager.sendPiezoStop()
}
@@ -1,216 +0,0 @@
package com.medithings.vesiscan.ui.views.calibration
import android.os.Handler
import android.os.Looper
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.R
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.managers.NirsManager
import com.medithings.vesiscan.models.NirsMagResponse
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
@Composable
fun CalibrationView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
val nirsManager = remember { NirsManager.instance }
val handler = remember { Handler(Looper.getMainLooper()) }
val initMsg = stringResource(R.string.calibration_initializing)
val completeMsg = stringResource(R.string.calibration_complete_status)
val noResponseMsg = stringResource(R.string.calibration_no_response)
val poweringMsg = stringResource(R.string.calibration_powering_on)
val activatingMsg = stringResource(R.string.calibration_activating)
val readingGainMsg = stringResource(R.string.calibration_reading_gain)
var statusMessage by remember { mutableStateOf(initMsg) }
var isComplete by remember { mutableStateOf(false) }
var isFailed by remember { mutableStateOf(false) }
var didReceiveResponse by remember { mutableStateOf(false) }
val isServiceReady by bleManager.isServiceReady
fun completeCalibration() {
if (isComplete) return
statusMessage = completeMsg
isComplete = true
}
fun failCalibration() {
if (isComplete || isFailed) return
statusMessage = noResponseMsg
isFailed = true
}
fun startCalibration() {
statusMessage = poweringMsg
bleManager.onNirsPowerOnReceived = {
didReceiveResponse = true
statusMessage = activatingMsg
handler.postDelayed({ bleManager.sendMqqQuery() }, 2000)
}
bleManager.onNirsSensorActivated = {
statusMessage = readingGainMsg
handler.postDelayed({ bleManager.sendMagQuery() }, 300)
}
bleManager.onNirsMagReceived = { data ->
NirsMagResponse.parse(data)?.let { response ->
nirsManager.setCalibrationGain(response)
}
completeCalibration()
}
bleManager.sendNirsPowerOn()
// Timeout
handler.postDelayed({
if (didReceiveResponse) completeCalibration() else failCalibration()
}, 6000)
}
fun retryCalibration() {
isFailed = false
isComplete = false
didReceiveResponse = false
statusMessage = initMsg
startCalibration()
}
LaunchedEffect(isServiceReady) {
if (isServiceReady) startCalibration()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(1f))
// Bladdy with checkmark
Box {
BladdyIdleRiveView(size = 160.dp)
if (isComplete) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MlTeal,
modifier = Modifier
.size(40.dp)
.align(Alignment.BottomEnd)
.offset(x = (-10).dp, y = (-10).dp)
)
}
}
Spacer(modifier = Modifier.height(32.dp))
Text(
text = when {
isFailed -> stringResource(R.string.calibration_failed)
isComplete -> stringResource(R.string.calibration_complete)
else -> stringResource(R.string.calibration_in_progress)
},
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = if (isFailed) Color.Red else Color.Unspecified
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = statusMessage,
fontSize = 16.sp,
color = MlSecondaryText,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp)
)
Spacer(modifier = Modifier.weight(1f))
if (isFailed) {
Column(
modifier = Modifier.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = { retryCalibration() },
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
RoundedCornerShape(16.dp)
),
contentAlignment = Alignment.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Refresh, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.retry), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
}
}
}
TextButton(
onClick = { appState.calibrationComplete() },
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(stringResource(R.string.calibration_skip), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
}
}
} else if (isComplete) {
Button(
onClick = { appState.calibrationComplete() },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
RoundedCornerShape(16.dp)
),
contentAlignment = Alignment.Center
) {
Text(stringResource(R.string.calibration_continue), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
}
}
}
Spacer(modifier = Modifier.weight(1f))
}
}
@@ -1,497 +0,0 @@
package com.medithings.vesiscan.ui.views.monitoring
import android.os.Handler
import android.os.Looper
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.R
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.managers.NirsManager
import com.medithings.vesiscan.models.NirsMcjPacket
import com.medithings.vesiscan.models.NirsOxyResult
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
import com.medithings.vesiscan.ui.theme.*
import kotlin.random.Random
@Composable
fun VivamyoMonitoringView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
val nirsManager = remember { NirsManager.instance }
val handler = remember { Handler(Looper.getMainLooper()) }
val screenWidth = LocalConfiguration.current.screenWidthDp
val isCompact = screenWidth < 400
var selectedTab by remember { mutableIntStateOf(0) }
var showClearConfirm by remember { mutableStateOf(false) }
var isToggling by remember { mutableStateOf(false) }
val batteryLvl by bleManager.batteryLevel
val isConnected by bleManager.isConnected
val isStreaming by nirsManager.isStreaming
val currentOxy by nirsManager.currentOxy
val sampleCount by nirsManager.sampleCount
val isFilterEnabled by nirsManager.isFilterEnabled
val isDemoMode = appState.isDemoMode
var demoRunnable by remember { mutableStateOf<Runnable?>(null) }
var demoBaseHbO2 by remember { mutableStateOf(12.0) }
var demoBaseHb by remember { mutableStateOf(8.0) }
var mcjRetryCount = remember { mutableIntStateOf(0) }
var mcjRetryRunnable by remember { mutableStateOf<Runnable?>(null) }
fun cancelMcjRetry() {
mcjRetryRunnable?.let { handler.removeCallbacks(it) }
mcjRetryRunnable = null
mcjRetryCount.intValue = 0
}
fun startNirsStream() {
cancelMcjRetry()
bleManager.onNirsMcjReceived = { data ->
cancelMcjRetry() // Got response, cancel retry
if (nirsManager.isStreaming.value) {
val packet = NirsMcjPacket.parse(data)
if (packet != null) {
nirsManager.processMcjPacket(packet)
} else {
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
android.util.Log.e("VivamyoMonitor", "NirsMcjPacket.parse returned null! data.size=${data.size}")
}
}
handler.postDelayed({
if (nirsManager.isStreaming.value) {
bleManager.sendMcjQuery()
// Retry if no response within 2s
val retry = Runnable {
if (nirsManager.isStreaming.value && mcjRetryCount.intValue < 3) {
mcjRetryCount.intValue++
bleManager.sendMcjQuery()
}
}
mcjRetryRunnable = retry
handler.postDelayed(retry, 2000)
}
}, 100)
}
}
// Start mcj chain directly (same as iOS)
bleManager.sendMcjQuery()
// Retry if first mcj gets no response within 2s
val initialRetry = Runnable {
if (nirsManager.isStreaming.value && mcjRetryCount.intValue < 5) {
mcjRetryCount.intValue++
bleManager.sendMcjQuery()
}
}
mcjRetryRunnable = initialRetry
handler.postDelayed(initialRetry, 2000)
}
fun startDemoStream() {
val tick = object : Runnable {
override fun run() {
if (!nirsManager.isStreaming.value) return
demoBaseHbO2 += Random.nextDouble(-0.3, 0.3)
demoBaseHb += Random.nextDouble(-0.2, 0.2)
val hbO2 = demoBaseHbO2.coerceIn(5.0, 25.0)
val hb = demoBaseHb.coerceIn(3.0, 15.0)
demoBaseHbO2 = hbO2
demoBaseHb = hb
val tHb = hbO2 + hb
val stO2 = if (tHb > 0) (hbO2 / tHb * 100.0) else 0.0
nirsManager.currentOxy.value = NirsOxyResult(hbO2, hb, tHb, stO2)
nirsManager.sampleCount.intValue++
nirsManager.oxyHbO2History.add(hbO2)
nirsManager.oxyHbHistory.add(hb)
if (nirsManager.oxyHbO2History.size > 240) nirsManager.oxyHbO2History.removeAt(0)
if (nirsManager.oxyHbHistory.size > 240) nirsManager.oxyHbHistory.removeAt(0)
handler.postDelayed(this, 250)
}
}
demoRunnable = tick
handler.post(tick)
}
fun stopDemoStream() {
demoRunnable?.let { handler.removeCallbacks(it) }
demoRunnable = null
nirsManager.stopStreaming()
}
fun stopNirsStream() {
// 1. Stop polling chain & clear callback first
nirsManager.stopStreaming()
cancelMcjRetry()
bleManager.onNirsMcjReceived = null
// 2. Resume battery polling after in-flight mcj response has time to arrive
handler.postDelayed({
bleManager.startBatteryPolling()
}, 500)
}
fun toggleStreaming() {
if (isToggling) return
isToggling = true
if (isStreaming) {
if (isDemoMode) stopDemoStream() else stopNirsStream()
} else {
if (isDemoMode) {
nirsManager.startStreaming()
startDemoStream()
} else {
bleManager.stopBatteryPolling()
nirsManager.startStreaming()
startNirsStream()
}
}
handler.postDelayed({ isToggling = false }, 1000)
}
// BLE 끊김 banner는 사내 테스트 피드백으로 제거됨 — BleManager 측 자동 재연결만 동작.
LaunchedEffect(Unit) {
bleManager.onUnexpectedDisconnect = { /* no-op: silent reconnect */ }
}
DisposableEffect(Unit) { onDispose {
if (isDemoMode) { stopDemoStream() }
else if (nirsManager.isStreaming.value) stopNirsStream()
else { cancelMcjRetry(); bleManager.onNirsMcjReceived = null }
} }
// Clear confirm dialog
if (showClearConfirm) {
AlertDialog(
onDismissRequest = { showClearConfirm = false },
title = { Text(stringResource(R.string.vivamyo_clear_title)) },
text = { Text(stringResource(R.string.vivamyo_clear_message)) },
confirmButton = {
TextButton(onClick = {
nirsManager.clearData()
showClearConfirm = false
}) { Text(stringResource(R.string.vivamyo_clear_button), color = Color.Red) }
},
dismissButton = {
TextButton(onClick = { showClearConfirm = false }) { Text(stringResource(R.string.cancel)) }
}
)
}
val stO2Color = when {
currentOxy.stO2 < 50 -> Color.Red
currentOxy.stO2 < 60 -> Color(0xFFFF9500)
else -> Color.Green
}
val stO2StatusText = when {
currentOxy.stO2 < 50 -> context.getString(R.string.vivamyo_status_low)
currentOxy.stO2 < 60 -> context.getString(R.string.vivamyo_status_caution)
currentOxy.stO2 <= 85 -> context.getString(R.string.vivamyo_status_normal)
else -> context.getString(R.string.vivamyo_status_check)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
// Top bar
val hPad = if (isCompact) 12.dp else 24.dp
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
nirsManager.reset()
appState.goHome()
}, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.Home, stringResource(R.string.vivamyo_home_desc), tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
}
Column(modifier = Modifier.padding(start = if (isCompact) 4.dp else 8.dp)) {
Text(stringResource(R.string.vivamyo_title), fontSize = if (isCompact) 16.sp else 20.sp, fontWeight = FontWeight.Bold)
if (!isCompact) Text(stringResource(R.string.vivamyo_subtitle), fontSize = 13.sp, color = MlSecondaryText)
}
Spacer(modifier = Modifier.weight(1f))
if (isDemoMode) {
Text("DEMO", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White,
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(Color(0xFFFF9500)).padding(horizontal = 4.dp, vertical = 2.dp))
} else {
Text("$batteryLvl%", fontSize = if (isCompact) 11.sp else 13.sp, color = MlSecondaryText)
}
Spacer(modifier = Modifier.width(if (isCompact) 4.dp else 8.dp))
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(if (isDemoMode || isConnected) Color.Green else Color.Red))
}
// Tab picker
TabRow(selectedTabIndex = selectedTab, modifier = Modifier.padding(horizontal = hPad)) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { Text("SpO2", modifier = Modifier.padding(if (isCompact) 8.dp else 12.dp)) }
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { Text("Oxy/Deoxy", modifier = Modifier.padding(if (isCompact) 8.dp else 12.dp)) }
}
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp),
verticalArrangement = Arrangement.spacedBy(if (isCompact) 10.dp else 16.dp)
) {
if (selectedTab == 0) {
// SpO2 tab
BladdyIdleRiveView(
size = if (isCompact) 80.dp else 120.dp,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Text(String.format("%.1f", currentOxy.stO2), fontSize = if (isCompact) 40.sp else 56.sp, fontWeight = FontWeight.Bold, color = stO2Color)
Text("% StO2", fontSize = 18.sp, color = MlSecondaryText)
Text(stO2StatusText, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = stO2Color,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(stO2Color.copy(alpha = 0.15f)).padding(horizontal = 12.dp, vertical = 4.dp))
}
NirsMetricsCard(nirsManager)
Row(modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.vivamyo_samples_label, sampleCount), fontSize = 11.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.weight(1f))
Text(stringResource(R.string.vivamyo_window_label), fontSize = 11.sp, color = MlSecondaryText)
}
} else {
// Oxy/Deoxy tab
LineChartCard("HbO2 (Oxy)", nirsManager.oxyHbO2History, Color.Red, "uM")
LineChartCard("HHb (DeOxy)", nirsManager.oxyHbHistory, Color.Blue, "uM")
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.vivamyo_samples_label, sampleCount), fontSize = 11.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.weight(1f))
if (isFilterEnabled) {
Text(stringResource(R.string.vivamyo_filtered), fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White,
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(Color(0xFF9966FF)).padding(horizontal = 6.dp, vertical = 2.dp))
Spacer(modifier = Modifier.width(4.dp))
}
Text(stringResource(R.string.vivamyo_window_label), fontSize = 11.sp, color = MlSecondaryText)
}
NirsMetricsCard(nirsManager)
}
}
// Control bar
Row(
modifier = Modifier.padding(horizontal = hPad).padding(bottom = if (isCompact) 16.dp else 32.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(
onClick = { showClearConfirm = true },
modifier = Modifier.weight(1f).height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.1f))
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Delete, null, tint = Color.Red)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.vivamyo_clear), fontWeight = FontWeight.SemiBold, color = Color.Red)
}
}
Button(
onClick = { toggleStreaming() },
modifier = Modifier.weight(1f).height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues(),
enabled = !isToggling
) {
Box(
modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
RoundedCornerShape(14.dp)
),
contentAlignment = Alignment.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (isStreaming) Icons.Default.Stop else Icons.Default.PlayArrow,
null, tint = Color.White
)
Spacer(modifier = Modifier.width(4.dp))
Text(if (isStreaming) stringResource(R.string.vivamyo_stop) else stringResource(R.string.vivamyo_start), fontWeight = FontWeight.SemiBold, color = Color.White)
}
}
}
}
}
}
@Composable
private fun NirsMetricsCard(nirsManager: NirsManager) {
val oxy by nirsManager.currentOxy
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.04f))
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetricBox("HbO2", oxy.hbO2, "uM", Color.Red, Modifier.weight(1f))
MetricBox("HHb", oxy.hb, "uM", Color.Blue, Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetricBox("tHb", oxy.tHb, "uM", Color(0xFF9966FF), Modifier.weight(1f))
MetricBox("StO2", oxy.stO2, "%", Color.Green, Modifier.weight(1f))
}
}
}
}
@Composable
private fun MetricBox(title: String, value: Double, unit: String, color: Color, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(MlBackground)
.padding(10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(color))
Spacer(modifier = Modifier.width(4.dp))
Text(title, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
}
Text(String.format("%.1f", value), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1C1B1F), modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.End)
Text(unit, fontSize = 10.sp, color = MlSecondaryText, modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.End)
}
}
@Composable
private fun LineChartCard(label: String, data: List<Double>, color: Color, unit: String) {
val axisColor = MlSecondaryText.copy(alpha = 0.4f)
val gridColor = MlSecondaryText.copy(alpha = 0.1f)
val sampleRate = 4f // Hz
val yLabelWidth = 36.dp
val xLabelHeight = 18.dp
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.04f))
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(6.dp).clip(CircleShape).background(color))
Spacer(modifier = Modifier.width(4.dp))
Text(label, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
Spacer(modifier = Modifier.weight(1f))
Text(
String.format("%.1f %s", data.lastOrNull() ?: 0.0, unit),
fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = color
)
}
Spacer(modifier = Modifier.height(8.dp))
if (data.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().height(140.dp),
contentAlignment = Alignment.Center
) { Text(stringResource(R.string.vivamyo_no_data), fontSize = 11.sp, color = MlSecondaryText) }
} else {
val minVal = data.min()
val maxVal = data.max()
val range = if (maxVal - minVal > 0.001) maxVal - minVal else 1.0
val yMin = minVal - range * 0.05
val yMax = maxVal + range * 0.05
val yRange = yMax - yMin
// Y-axis tick values (5 ticks)
val yTicks = (0..4).map { yMin + yRange * it / 4.0 }
Row(modifier = Modifier.fillMaxWidth().height(140.dp + xLabelHeight)) {
// Y-axis labels
Column(
modifier = Modifier.width(yLabelWidth).height(140.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
yTicks.reversed().forEach { tick ->
Text(
String.format("%.1f", tick),
fontSize = 8.sp,
color = MlSecondaryText,
maxLines = 1
)
}
}
Column {
// Chart area
Canvas(modifier = Modifier.fillMaxWidth().height(140.dp)) {
// Grid lines
for (i in 0..4) {
val y = size.height * i / 4f
drawLine(gridColor, Offset(0f, y), Offset(size.width, y), strokeWidth = 1f)
}
// Data line
if (data.size >= 2) {
val stepX = size.width / (data.size - 1).toFloat()
val path = Path()
data.forEachIndexed { index, value ->
val x = index * stepX
val y = size.height - ((value - yMin) / yRange * size.height).toFloat()
if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(path, color = color, style = Stroke(width = 3f))
}
}
// X-axis labels
val totalSeconds = data.size / sampleRate
Row(
modifier = Modifier.fillMaxWidth().height(xLabelHeight),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(String.format("%.0fs", 0f), fontSize = 8.sp, color = MlSecondaryText)
Text(String.format("%.0fs", totalSeconds / 2), fontSize = 8.sp, color = MlSecondaryText)
Text(String.format("%.0fs", totalSeconds), fontSize = 8.sp, color = MlSecondaryText)
}
}
}
// Unit label
Text(unit, fontSize = 8.sp, color = MlSecondaryText, modifier = Modifier.padding(start = yLabelWidth))
}
}
}
}
@@ -54,15 +54,6 @@ fun SensorSelectView(appState: AppState) {
modifier = Modifier.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
SensorCard(
mode = SensorMode.NIRS,
title = "NIRS",
description = stringResource(R.string.sensor_nirs_desc),
gradientColors = listOf(Color(0xFF00DBA1), Color(0xFF008FFF)),
iconText = "NIRS",
onClick = { appState.selectSensor(SensorMode.NIRS) }
)
SensorCard(
mode = SensorMode.PIEZO,
title = "Piezo",