diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index 6f28a47..3421eee 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -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) { diff --git a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt index 7923dc7..49167d7 100644 --- a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt +++ b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt @@ -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 }) diff --git a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt index ac93476..324f175 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -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) diff --git a/app/src/main/java/com/medithings/vesiscan/managers/NirsCalcEngine.kt b/app/src/main/java/com/medithings/vesiscan/managers/NirsCalcEngine.kt deleted file mode 100644 index 2bfe6ce..0000000 --- a/app/src/main/java/com/medithings/vesiscan/managers/NirsCalcEngine.kt +++ /dev/null @@ -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 = 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 { - 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) - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/managers/NirsManager.kt b/app/src/main/java/com/medithings/vesiscan/managers/NirsManager.kt deleted file mode 100644 index 4e3d7b8..0000000 --- a/app/src/main/java/com/medithings/vesiscan/managers/NirsManager.kt +++ /dev/null @@ -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() - val oxyHbHistory = mutableStateListOf() - - // 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, value: Double) { - list.add(value) - while (list.size > maxPoints) list.removeAt(0) - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/models/NirsConstants.kt b/app/src/main/java/com/medithings/vesiscan/models/NirsConstants.kt deleted file mode 100644 index e9eaae2..0000000 --- a/app/src/main/java/com/medithings/vesiscan/models/NirsConstants.kt +++ /dev/null @@ -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 -} diff --git a/app/src/main/java/com/medithings/vesiscan/models/NirsData.kt b/app/src/main/java/com/medithings/vesiscan/models/NirsData.kt deleted file mode 100644 index f7db6b0..0000000 --- a/app/src/main/java/com/medithings/vesiscan/models/NirsData.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/models/SensorMode.kt b/app/src/main/java/com/medithings/vesiscan/models/SensorMode.kt index fcc86f7..1fd28fc 100644 --- a/app/src/main/java/com/medithings/vesiscan/models/SensorMode.kt +++ b/app/src/main/java/com/medithings/vesiscan/models/SensorMode.kt @@ -1,6 +1,6 @@ package com.medithings.vesiscan.models +// 2026-08-12: NIRS 제거. 향후 다른 센서 추가 대비해 enum 유지. enum class SensorMode(val displayName: String) { - NIRS("NIRS"), PIEZO("Piezo") } diff --git a/app/src/main/java/com/medithings/vesiscan/services/MeasurementService.kt b/app/src/main/java/com/medithings/vesiscan/services/MeasurementService.kt index c7917d7..b794e8b 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/MeasurementService.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/MeasurementService.kt @@ -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() } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/calibration/CalibrationView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/calibration/CalibrationView.kt deleted file mode 100644 index a1043bf..0000000 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/calibration/CalibrationView.kt +++ /dev/null @@ -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)) - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/VivamyoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/VivamyoMonitoringView.kt deleted file mode 100644 index 41d4e9f..0000000 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/VivamyoMonitoringView.kt +++ /dev/null @@ -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(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(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, 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)) - } - } - } -} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/sensorselect/SensorSelectView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/sensorselect/SensorSelectView.kt index c0d2597..7f7e74f 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/sensorselect/SensorSelectView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/sensorselect/SensorSelectView.kt @@ -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", diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d5f826b..1271d5f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -234,20 +234,9 @@ 센서 선택 측정 방식을 선택하세요 - 근적외선 분광법\n조직 산소화 모니터링 초음파 에코\n방광 용량 측정 - 초기화 중… - NIRS 보정 완료 - 기기 응답이 없습니다. 기기가 켜져 있고 가까이 있는지 확인해주세요. - NIRS 센서 켜는 중… - 센서 활성화 중… - 보정값 읽는 중… - 보정 실패 - 보정 완료 - NIRS 보정 중… - 건너뛰기 계속 @@ -269,23 +258,6 @@ 오늘 - Vivamyo - NIRS 모니터링 - 모든 데이터를 지우시겠습니까? - 수집된 NIRS 데이터가 영구적으로 삭제됩니다. - 데이터 지우기 - 낮음 - 주의 - 정상 - 확인 필요 - 샘플: %d - 4Hz | 60초 윈도우 - 필터 적용 - 데이터 없음 - 지우기 - 중지 - 시작 - 홈 기록됨 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9ad611..2a37804 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -234,20 +234,9 @@ Select Sensor Choose your measurement mode - Near-Infrared Spectroscopy\nTissue oxygenation monitoring Ultrasound Echo\nBladder volume measurement - Initializing… - NIRS Calibrated - No response from device. Check the device is powered on and nearby. - Powering on NIRS transducer… - Activating sensor… - Reading gain calibration… - Calibration Failed - Calibration Complete - Calibrating NIRS… - Skip Continue @@ -269,23 +258,6 @@ Today - Vivamyo - NIRS Monitoring - Clear all data? - All collected NIRS data will be permanently deleted. - Clear Data - Low - Caution - Normal - Check - Samples: %d - 4Hz | 60s window - Filtered - No data - Clear - Stop - Start - Home Recorded