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:
@@ -6,15 +6,14 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import com.medithings.vesiscan.ble.BleManager
|
import com.medithings.vesiscan.ble.BleManager
|
||||||
import com.medithings.vesiscan.managers.NirsManager
|
|
||||||
import com.medithings.vesiscan.models.*
|
import com.medithings.vesiscan.models.*
|
||||||
import com.medithings.vesiscan.services.*
|
import com.medithings.vesiscan.services.*
|
||||||
|
|
||||||
enum class AppScreen {
|
enum class AppScreen {
|
||||||
ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN,
|
ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN,
|
||||||
NIRS_CALIBRATION, PIEZO_PERSONALIZATION,
|
PIEZO_PERSONALIZATION,
|
||||||
PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
|
PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
|
||||||
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
||||||
VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS,
|
VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS,
|
||||||
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
|
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
|
||||||
CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
|
CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
|
||||||
@@ -93,8 +92,6 @@ class AppState(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun goHome() {
|
fun goHome() {
|
||||||
NirsManager.instance.stopStreaming()
|
|
||||||
NirsManager.instance.reset()
|
|
||||||
// 회의 결정: 실제 BLE 연결은 HOME에서도 유지. 다음 Start 시 설정/scan skip하고
|
// 회의 결정: 실제 BLE 연결은 HOME에서도 유지. 다음 Start 시 설정/scan skip하고
|
||||||
// 바로 PiezoMonitoring으로 진입할 수 있도록.
|
// 바로 PiezoMonitoring으로 진입할 수 있도록.
|
||||||
// Demo 모드는 가짜 연결이므로 isDeviceConnected reset.
|
// Demo 모드는 가짜 연결이므로 isDeviceConnected reset.
|
||||||
@@ -106,11 +103,7 @@ class AppState(private val context: Context) {
|
|||||||
fun enterDemoMode() {
|
fun enterDemoMode() {
|
||||||
isDemoMode = true
|
isDemoMode = true
|
||||||
isDeviceConnected = true
|
isDeviceConnected = true
|
||||||
currentScreen = if (sensorMode == SensorMode.NIRS) {
|
currentScreen = AppScreen.PIEZO_MONITORING
|
||||||
AppScreen.VIVAMYO_MONITORING
|
|
||||||
} else {
|
|
||||||
AppScreen.PIEZO_MONITORING
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startFromHome() {
|
fun startFromHome() {
|
||||||
@@ -138,15 +131,7 @@ class AppState(private val context: Context) {
|
|||||||
currentScreen = AppScreen.CLINICAL_HOME
|
currentScreen = AppScreen.CLINICAL_HOME
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentScreen = if (sensorMode == SensorMode.NIRS) {
|
currentScreen = AppScreen.PIEZO_PERSONALIZATION
|
||||||
AppScreen.NIRS_CALIBRATION
|
|
||||||
} else {
|
|
||||||
AppScreen.PIEZO_PERSONALIZATION
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun calibrationComplete() {
|
|
||||||
currentScreen = AppScreen.VIVAMYO_MONITORING
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun personalizationComplete() {
|
fun personalizationComplete() {
|
||||||
@@ -299,7 +284,7 @@ class AppState(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun backToMonitoring() {
|
fun backToMonitoring() {
|
||||||
currentScreen = if (sensorMode == SensorMode.NIRS) AppScreen.VIVAMYO_MONITORING else AppScreen.PIEZO_MONITORING
|
currentScreen = AppScreen.PIEZO_MONITORING
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addCatheterCount(amount: Int = 1) {
|
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.services.NotificationService
|
||||||
import com.medithings.vesiscan.R
|
import com.medithings.vesiscan.R
|
||||||
import com.medithings.vesiscan.ui.theme.Medilightv2androidTheme
|
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.connection.DeviceScanView
|
||||||
import com.medithings.vesiscan.ui.views.history.MeasurementHistoryView
|
import com.medithings.vesiscan.ui.views.history.MeasurementHistoryView
|
||||||
import com.medithings.vesiscan.ui.views.home.HomeView
|
import com.medithings.vesiscan.ui.views.home.HomeView
|
||||||
import com.medithings.vesiscan.ui.views.monitoring.PlacementGuideView
|
import com.medithings.vesiscan.ui.views.monitoring.PlacementGuideView
|
||||||
import com.medithings.vesiscan.ui.views.monitoring.PiezoMonitoringView
|
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.UrineCameraScreen
|
||||||
import com.medithings.vesiscan.ui.views.monitoring.VoidingDiaryView
|
import com.medithings.vesiscan.ui.views.monitoring.VoidingDiaryView
|
||||||
import com.medithings.vesiscan.ui.views.onboarding.OnboardingView
|
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.FIRMWARE_UPDATE -> appState.currentScreen = AppScreen.HOME
|
||||||
// 기타
|
// 기타
|
||||||
AppScreen.SENSOR_SELECT -> 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으로)
|
// 온보딩/등록 (뒤로가기 막지 않되 Home으로)
|
||||||
else -> appState.currentScreen = AppScreen.HOME
|
else -> appState.currentScreen = AppScreen.HOME
|
||||||
}
|
}
|
||||||
@@ -175,12 +171,10 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
|||||||
AppScreen.HOME -> HomeView(appState)
|
AppScreen.HOME -> HomeView(appState)
|
||||||
AppScreen.SENSOR_SELECT -> SensorSelectView(appState)
|
AppScreen.SENSOR_SELECT -> SensorSelectView(appState)
|
||||||
AppScreen.DEVICE_SCAN -> DeviceScanView(appState)
|
AppScreen.DEVICE_SCAN -> DeviceScanView(appState)
|
||||||
AppScreen.NIRS_CALIBRATION -> CalibrationView(appState)
|
|
||||||
AppScreen.PIEZO_PERSONALIZATION -> PiezoPersonalizationView(appState)
|
AppScreen.PIEZO_PERSONALIZATION -> PiezoPersonalizationView(appState)
|
||||||
AppScreen.PLACEMENT_GUIDE -> PlacementGuideView(appState)
|
AppScreen.PLACEMENT_GUIDE -> PlacementGuideView(appState)
|
||||||
AppScreen.PIN_SETUP -> PinView(appState, PinMode.SETUP, context)
|
AppScreen.PIN_SETUP -> PinView(appState, PinMode.SETUP, context)
|
||||||
AppScreen.PIN_ENTRY -> PinView(appState, PinMode.ENTER, context)
|
AppScreen.PIN_ENTRY -> PinView(appState, PinMode.ENTER, context)
|
||||||
AppScreen.VIVAMYO_MONITORING -> VivamyoMonitoringView(appState)
|
|
||||||
AppScreen.PIEZO_MONITORING -> PiezoMonitoringView(appState)
|
AppScreen.PIEZO_MONITORING -> PiezoMonitoringView(appState)
|
||||||
AppScreen.MEASUREMENT_HISTORY -> MeasurementHistoryView(appState)
|
AppScreen.MEASUREMENT_HISTORY -> MeasurementHistoryView(appState)
|
||||||
AppScreen.VOIDING_DIARY -> VoidingDiaryView(onBack = { appState.currentScreen = AppScreen.HOME })
|
AppScreen.VOIDING_DIARY -> VoidingDiaryView(onBack = { appState.currentScreen = AppScreen.HOME })
|
||||||
|
|||||||
@@ -287,10 +287,6 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
var onConnectionStateChanged: ((Boolean) -> Unit)? = null
|
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 onPiezoDataReceived: ((ByteArray) -> Unit)? = null
|
||||||
var onUnexpectedDisconnect: (() -> Unit)? = null
|
var onUnexpectedDisconnect: (() -> Unit)? = null
|
||||||
var onReconnectionFailed: (() -> Unit)? = null
|
var onReconnectionFailed: (() -> Unit)? = null
|
||||||
@@ -717,10 +713,6 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
"mps" -> "piezo stop"
|
"mps" -> "piezo stop"
|
||||||
"mec" -> "single burst"
|
"mec" -> "single burst"
|
||||||
"msn" -> "battery query"
|
"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"
|
"mid" -> "device info query"
|
||||||
// mls 는 buildCommandBE 로 16bit BE 인코딩 → data[4]=High, data[5]=Low
|
// mls 는 buildCommandBE 로 16bit BE 인코딩 → data[4]=High, data[5]=Low
|
||||||
"mls" -> "LED mode ${
|
"mls" -> "LED mode ${
|
||||||
@@ -831,28 +823,6 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
return true
|
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 ──
|
// ── Common Commands ──
|
||||||
|
|
||||||
fun sendBatteryQuery() {
|
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)
|
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
|
||||||
logd { "Received: $prefix (${data.size} bytes)" }
|
logd { "Received: $prefix (${data.size} bytes)" }
|
||||||
when (prefix) {
|
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:" -> {
|
"rpa:" -> {
|
||||||
debugLogger.rx("rpa", data.size, "piezo ack")
|
debugLogger.rx("rpa", data.size, "piezo ack")
|
||||||
onPiezoDataReceived?.invoke(data)
|
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
|
package com.medithings.vesiscan.models
|
||||||
|
|
||||||
|
// 2026-08-12: NIRS 제거. 향후 다른 센서 추가 대비해 enum 유지.
|
||||||
enum class SensorMode(val displayName: String) {
|
enum class SensorMode(val displayName: String) {
|
||||||
NIRS("NIRS"),
|
|
||||||
PIEZO("Piezo")
|
PIEZO("Piezo")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,25 +19,11 @@ class MeasurementService private constructor(context: Context) {
|
|||||||
|
|
||||||
val isMeasuring = mutableStateOf(false)
|
val isMeasuring = mutableStateOf(false)
|
||||||
|
|
||||||
|
// 2026-08-12: NIRS 제거 후 PIEZO 만 남음. 시그니처는 향후 센서 추가 대비 유지.
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
fun performMeasurement(mode: SensorMode, completion: (Boolean) -> Unit) {
|
fun performMeasurement(mode: SensorMode, completion: (Boolean) -> Unit) {
|
||||||
isMeasuring.value = true
|
isMeasuring.value = true
|
||||||
when (mode) {
|
performPiezoMeasurement(completion)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun performPiezoMeasurement(completion: (Boolean) -> Unit) {
|
private fun performPiezoMeasurement(completion: (Boolean) -> Unit) {
|
||||||
@@ -52,8 +38,6 @@ class MeasurementService private constructor(context: Context) {
|
|||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
isMeasuring.value = false
|
isMeasuring.value = false
|
||||||
// 대기 중이던 콜백 취소.
|
|
||||||
bleManager.onNirsMcjReceived = null
|
|
||||||
bleManager.onPiezoDataReceived = null
|
bleManager.onPiezoDataReceived = null
|
||||||
bleManager.sendPiezoStop()
|
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-497
@@ -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),
|
modifier = Modifier.padding(horizontal = 32.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.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(
|
SensorCard(
|
||||||
mode = SensorMode.PIEZO,
|
mode = SensorMode.PIEZO,
|
||||||
title = "Piezo",
|
title = "Piezo",
|
||||||
|
|||||||
@@ -234,20 +234,9 @@
|
|||||||
<!-- Sensor Select -->
|
<!-- Sensor Select -->
|
||||||
<string name="sensor_select_title">센서 선택</string>
|
<string name="sensor_select_title">센서 선택</string>
|
||||||
<string name="sensor_select_subtitle">측정 방식을 선택하세요</string>
|
<string name="sensor_select_subtitle">측정 방식을 선택하세요</string>
|
||||||
<string name="sensor_nirs_desc">근적외선 분광법\n조직 산소화 모니터링</string>
|
|
||||||
<string name="sensor_piezo_desc">초음파 에코\n방광 용량 측정</string>
|
<string name="sensor_piezo_desc">초음파 에코\n방광 용량 측정</string>
|
||||||
|
|
||||||
<!-- Calibration -->
|
<!-- Calibration -->
|
||||||
<string name="calibration_initializing">초기화 중…</string>
|
|
||||||
<string name="calibration_complete_status">NIRS 보정 완료</string>
|
|
||||||
<string name="calibration_no_response">기기 응답이 없습니다. 기기가 켜져 있고 가까이 있는지 확인해주세요.</string>
|
|
||||||
<string name="calibration_powering_on">NIRS 센서 켜는 중…</string>
|
|
||||||
<string name="calibration_activating">센서 활성화 중…</string>
|
|
||||||
<string name="calibration_reading_gain">보정값 읽는 중…</string>
|
|
||||||
<string name="calibration_failed">보정 실패</string>
|
|
||||||
<string name="calibration_complete">보정 완료</string>
|
|
||||||
<string name="calibration_in_progress">NIRS 보정 중…</string>
|
|
||||||
<string name="calibration_skip">건너뛰기</string>
|
|
||||||
<string name="calibration_continue">계속</string>
|
<string name="calibration_continue">계속</string>
|
||||||
|
|
||||||
<!-- Measurement History -->
|
<!-- Measurement History -->
|
||||||
@@ -269,23 +258,6 @@
|
|||||||
<string name="history_today">오늘</string>
|
<string name="history_today">오늘</string>
|
||||||
|
|
||||||
<!-- Vivamyo (NIRS Monitoring) -->
|
<!-- Vivamyo (NIRS Monitoring) -->
|
||||||
<string name="vivamyo_title">Vivamyo</string>
|
|
||||||
<string name="vivamyo_subtitle">NIRS 모니터링</string>
|
|
||||||
<string name="vivamyo_clear_title">모든 데이터를 지우시겠습니까?</string>
|
|
||||||
<string name="vivamyo_clear_message">수집된 NIRS 데이터가 영구적으로 삭제됩니다.</string>
|
|
||||||
<string name="vivamyo_clear_button">데이터 지우기</string>
|
|
||||||
<string name="vivamyo_status_low">낮음</string>
|
|
||||||
<string name="vivamyo_status_caution">주의</string>
|
|
||||||
<string name="vivamyo_status_normal">정상</string>
|
|
||||||
<string name="vivamyo_status_check">확인 필요</string>
|
|
||||||
<string name="vivamyo_samples_label">샘플: %d</string>
|
|
||||||
<string name="vivamyo_window_label">4Hz | 60초 윈도우</string>
|
|
||||||
<string name="vivamyo_filtered">필터 적용</string>
|
|
||||||
<string name="vivamyo_no_data">데이터 없음</string>
|
|
||||||
<string name="vivamyo_clear">지우기</string>
|
|
||||||
<string name="vivamyo_stop">중지</string>
|
|
||||||
<string name="vivamyo_start">시작</string>
|
|
||||||
<string name="vivamyo_home_desc">홈</string>
|
|
||||||
|
|
||||||
<!-- Piezo Monitoring -->
|
<!-- Piezo Monitoring -->
|
||||||
<string name="piezo_recorded">기록됨</string>
|
<string name="piezo_recorded">기록됨</string>
|
||||||
|
|||||||
@@ -234,20 +234,9 @@
|
|||||||
<!-- Sensor Select -->
|
<!-- Sensor Select -->
|
||||||
<string name="sensor_select_title">Select Sensor</string>
|
<string name="sensor_select_title">Select Sensor</string>
|
||||||
<string name="sensor_select_subtitle">Choose your measurement mode</string>
|
<string name="sensor_select_subtitle">Choose your measurement mode</string>
|
||||||
<string name="sensor_nirs_desc">Near-Infrared Spectroscopy\nTissue oxygenation monitoring</string>
|
|
||||||
<string name="sensor_piezo_desc">Ultrasound Echo\nBladder volume measurement</string>
|
<string name="sensor_piezo_desc">Ultrasound Echo\nBladder volume measurement</string>
|
||||||
|
|
||||||
<!-- Calibration -->
|
<!-- Calibration -->
|
||||||
<string name="calibration_initializing">Initializing…</string>
|
|
||||||
<string name="calibration_complete_status">NIRS Calibrated</string>
|
|
||||||
<string name="calibration_no_response">No response from device. Check the device is powered on and nearby.</string>
|
|
||||||
<string name="calibration_powering_on">Powering on NIRS transducer…</string>
|
|
||||||
<string name="calibration_activating">Activating sensor…</string>
|
|
||||||
<string name="calibration_reading_gain">Reading gain calibration…</string>
|
|
||||||
<string name="calibration_failed">Calibration Failed</string>
|
|
||||||
<string name="calibration_complete">Calibration Complete</string>
|
|
||||||
<string name="calibration_in_progress">Calibrating NIRS…</string>
|
|
||||||
<string name="calibration_skip">Skip</string>
|
|
||||||
<string name="calibration_continue">Continue</string>
|
<string name="calibration_continue">Continue</string>
|
||||||
|
|
||||||
<!-- Measurement History -->
|
<!-- Measurement History -->
|
||||||
@@ -269,23 +258,6 @@
|
|||||||
<string name="history_today">Today</string>
|
<string name="history_today">Today</string>
|
||||||
|
|
||||||
<!-- Vivamyo (NIRS Monitoring) -->
|
<!-- Vivamyo (NIRS Monitoring) -->
|
||||||
<string name="vivamyo_title">Vivamyo</string>
|
|
||||||
<string name="vivamyo_subtitle">NIRS Monitoring</string>
|
|
||||||
<string name="vivamyo_clear_title">Clear all data?</string>
|
|
||||||
<string name="vivamyo_clear_message">All collected NIRS data will be permanently deleted.</string>
|
|
||||||
<string name="vivamyo_clear_button">Clear Data</string>
|
|
||||||
<string name="vivamyo_status_low">Low</string>
|
|
||||||
<string name="vivamyo_status_caution">Caution</string>
|
|
||||||
<string name="vivamyo_status_normal">Normal</string>
|
|
||||||
<string name="vivamyo_status_check">Check</string>
|
|
||||||
<string name="vivamyo_samples_label">Samples: %d</string>
|
|
||||||
<string name="vivamyo_window_label">4Hz | 60s window</string>
|
|
||||||
<string name="vivamyo_filtered">Filtered</string>
|
|
||||||
<string name="vivamyo_no_data">No data</string>
|
|
||||||
<string name="vivamyo_clear">Clear</string>
|
|
||||||
<string name="vivamyo_stop">Stop</string>
|
|
||||||
<string name="vivamyo_start">Start</string>
|
|
||||||
<string name="vivamyo_home_desc">Home</string>
|
|
||||||
|
|
||||||
<!-- Piezo Monitoring -->
|
<!-- Piezo Monitoring -->
|
||||||
<string name="piezo_recorded">Recorded</string>
|
<string name="piezo_recorded">Recorded</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user