fix+UX: BLE 중복수신 수정, peak detection 개선, UI 대폭 정리

BLE:
- GATT 다중연결 race condition 수정 (reconnectConnecting 플래그)
- onCharacteristicChanged API 레벨 분기 (dual callback 방지)
- DeviceScan: isConnected → isServiceReady 전환 (서비스 준비 후 화면전환)
- Placement: mpa/maa 500ms 딜레이 (power on 대기)
- Disconnect & Unbond 기능 추가 (msr? + removeBond + 저장정보 삭제)
- autoConnect 삭제 (HomeView)

Algorithm:
- 후위벽 피크 >= threshold 조건 추가 (false positive 차단)
- low-echo 분석범위 80샘플 제한 (뒤쪽 노이즈 무시)
- urineLen: post-ant-1 → post-ant 수정
- Placement: UrinAI fallback 제거 (PiezoEchoAnalyzer만 사용)
- 기기 프리셋 autoDetect index 7→8 수정 (VBT26040Nxx)
- dps 기본값 1.771 → 1.968

UI:
- 도넛차트: Home버튼 삭제, Auto/Catheter count/History 삭제
- 3버튼 레이아웃 (Catheterize/Auto/Spot)
- 배터리 아이콘 (60+초록/30+주황/빨강) + 설정 톱니바퀴 분리
- SensorSelect/Consent 페이지 스킵
- 온보딩: VesiScan-Basic, NIRS 페이지 삭제
- MaxVolume: Step 표시 삭제, mL 오른쪽 배치
- BackHandler 전체 재정리 (자연스러운 뒤로가기)
- BLE 신호 기준 완화 (Good≥-80, Fair≥-90)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 17:03:37 +09:00
parent 227480af4b
commit 5cdae8a86e
11 changed files with 254 additions and 197 deletions
@@ -49,7 +49,6 @@ class AppState(private val context: Context) {
currentScreen = when { currentScreen = when {
!storage.hasCompletedOnboarding -> AppScreen.ONBOARDING !storage.hasCompletedOnboarding -> AppScreen.ONBOARDING
!storage.hasConsented -> AppScreen.CONSENT
!storage.isRegistered -> AppScreen.REGISTRATION !storage.isRegistered -> AppScreen.REGISTRATION
!storage.hasPin -> AppScreen.PIN_SETUP !storage.hasPin -> AppScreen.PIN_SETUP
else -> AppScreen.PIN_ENTRY else -> AppScreen.PIN_ENTRY
@@ -58,7 +57,8 @@ class AppState(private val context: Context) {
fun completeOnboarding() { fun completeOnboarding() {
storage.hasCompletedOnboarding = true storage.hasCompletedOnboarding = true
currentScreen = AppScreen.CONSENT storage.hasConsented = true
currentScreen = AppScreen.REGISTRATION
} }
fun completeConsent() { fun completeConsent() {
@@ -93,7 +93,9 @@ class AppState(private val context: Context) {
} }
fun startFromHome() { fun startFromHome() {
currentScreen = AppScreen.SENSOR_SELECT sensorMode = SensorMode.PIEZO
storage.sensorMode = SensorMode.PIEZO
currentScreen = AppScreen.DEVICE_SCAN
} }
fun selectSensor(mode: SensorMode) { fun selectSensor(mode: SensorMode) {
@@ -103,7 +105,7 @@ class AppState(private val context: Context) {
} }
fun backToSensorSelect() { fun backToSensorSelect() {
currentScreen = AppScreen.SENSOR_SELECT currentScreen = AppScreen.HOME
} }
fun deviceConnected() { fun deviceConnected() {
@@ -61,19 +61,25 @@ fun MedilightApp(modifier: Modifier = Modifier) {
val context = LocalContext.current val context = LocalContext.current
val appState = remember { AppState(context) } val appState = remember { AppState(context) }
// Android 뒤로가기 = 앱 내 뒤로가기 // Android 뒤로가기 = 바로 전 화면
BackHandler(enabled = appState.currentScreen != AppScreen.HOME) { BackHandler(enabled = appState.currentScreen != AppScreen.HOME
&& appState.currentScreen != AppScreen.PIN_ENTRY) {
when (appState.currentScreen) { when (appState.currentScreen) {
AppScreen.SENSOR_SELECT -> appState.currentScreen = AppScreen.HOME // 메인 플로우: Home → DeviceScan → Personalization → Placement → Monitoring
AppScreen.DEVICE_SCAN -> appState.currentScreen = AppScreen.SENSOR_SELECT AppScreen.DEVICE_SCAN -> appState.currentScreen = AppScreen.HOME
AppScreen.PIEZO_PERSONALIZATION -> appState.currentScreen = AppScreen.DEVICE_SCAN AppScreen.PIEZO_PERSONALIZATION -> appState.currentScreen = AppScreen.DEVICE_SCAN
AppScreen.PLACEMENT_GUIDE -> appState.goHome() AppScreen.PLACEMENT_GUIDE -> appState.currentScreen = AppScreen.PIEZO_PERSONALIZATION
AppScreen.PIEZO_MONITORING -> appState.currentScreen = AppScreen.PLACEMENT_GUIDE AppScreen.PIEZO_MONITORING -> appState.currentScreen = AppScreen.PLACEMENT_GUIDE
AppScreen.VIVAMYO_MONITORING -> appState.goHome() // 모니터링 하위 화면 → 모니터링
AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
AppScreen.REMINDER_SETTINGS -> appState.currentScreen = AppScreen.PIEZO_MONITORING AppScreen.REMINDER_SETTINGS -> appState.currentScreen = AppScreen.PIEZO_MONITORING
AppScreen.URINE_CAMERA -> appState.currentScreen = AppScreen.PIEZO_MONITORING AppScreen.URINE_CAMERA -> appState.currentScreen = AppScreen.PIEZO_MONITORING
// 기타
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 else -> appState.currentScreen = AppScreen.HOME
} }
} }
@@ -166,6 +166,12 @@ class BleManager private constructor(private val context: Context) {
// Connection // Connection
fun connect(bleDevice: BleDevice) { fun connect(bleDevice: BleDevice) {
stopScan() stopScan()
cancelAutoReconnect()
reconnectScanCallback?.let { cb ->
try { bluetoothAdapter?.bluetoothLeScanner?.stopScan(cb) } catch (_: Exception) {}
reconnectScanCallback = null
}
reconnectConnecting = false
logd { "Connecting to ${bleDevice.name} (${bleDevice.address})" } logd { "Connecting to ${bleDevice.name} (${bleDevice.address})" }
// Clean up any existing GATT connection // Clean up any existing GATT connection
@@ -241,6 +247,55 @@ class BleManager private constructor(private val context: Context) {
bluetoothGatt?.disconnect() bluetoothGatt?.disconnect()
} }
fun disconnectAndUnbond() {
isUserDisconnect = true
cancelAutoReconnect()
stopWatchdog()
stopBatteryPolling()
connectionTimer?.let { handler.removeCallbacks(it) }
connectionTimer = null
// 기기에 본딩 삭제 + 재부팅 명령 (msr?)
sendRaw(CRC16.buildCommandASCII("msr", " "))
debugLogger.info("TX msr? unbond & reboot device")
val device = bluetoothGatt?.device
// 기기가 재부팅되므로 약간 대기 후 GATT 정리
handler.postDelayed({
bluetoothGatt?.let { gatt ->
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
}
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
isConnected.value = false
isServiceReady.value = false
connectedDeviceName.value = ""
batteryLevel.value = 0
lastConnectedAddress = null
// Android 측 본딩 정보도 삭제
device?.let { dev ->
try {
val method = dev.javaClass.getMethod("removeBond")
method.invoke(dev)
logd { "removeBond called for ${dev.address}" }
} catch (e: Exception) {
logw { "removeBond failed: ${e.message}" }
}
}
// 저장된 기기 정보 삭제
try {
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
prefs.edit().remove("last_device_address").remove("last_device_name").apply()
} catch (_: Exception) {}
debugLogger.info("DEVICE_UNREGISTERED")
}, 300)
}
// Reconnection // Reconnection
fun reconnectToLastDevice(lastDeviceAddress: String?) { fun reconnectToLastDevice(lastDeviceAddress: String?) {
if (lastDeviceAddress == null || bluetoothAdapter?.isEnabled != true) { if (lastDeviceAddress == null || bluetoothAdapter?.isEnabled != true) {
@@ -248,6 +303,13 @@ class BleManager private constructor(private val context: Context) {
return return
} }
// Stop any active reconnect scan first
reconnectScanCallback?.let { cb ->
try { bluetoothAdapter.bluetoothLeScanner?.stopScan(cb) } catch (_: Exception) {}
reconnectScanCallback = null
}
reconnectConnecting = false
// Clean up existing // Clean up existing
bluetoothGatt?.let { gatt -> bluetoothGatt?.let { gatt ->
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {} try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
@@ -481,6 +543,7 @@ class BleManager private constructor(private val context: Context) {
} }
private var reconnectScanCallback: ScanCallback? = null private var reconnectScanCallback: ScanCallback? = null
@Volatile private var reconnectConnecting = false
private fun attemptAutoReconnect() { private fun attemptAutoReconnect() {
val targetAddress = lastConnectedAddress ?: return val targetAddress = lastConnectedAddress ?: return
@@ -492,11 +555,12 @@ class BleManager private constructor(private val context: Context) {
} }
reconnectAttempt++ reconnectAttempt++
reconnectConnecting = false
logd { "Auto-reconnect #$reconnectAttempt: scanning for $targetName ($targetAddress)" } logd { "Auto-reconnect #$reconnectAttempt: scanning for $targetName ($targetAddress)" }
debugLogger.info("RECONNECT_SCAN #$reconnectAttempt scanning for $targetName") debugLogger.info("RECONNECT_SCAN #$reconnectAttempt scanning for $targetName")
bluetoothGatt?.let { gatt -> bluetoothGatt?.let { gatt ->
try { gatt.close() } catch (_: Exception) {} try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
bluetoothGatt = null bluetoothGatt = null
} }
@@ -514,17 +578,18 @@ class BleManager private constructor(private val context: Context) {
val callback = object : ScanCallback() { val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) { override fun onScanResult(callbackType: Int, result: ScanResult) {
if (reconnectConnecting) return
val name = try { result.device.name ?: "" } catch (_: Exception) { "" } val name = try { result.device.name ?: "" } catch (_: Exception) { "" }
val address = result.device.address val address = result.device.address
if (address == targetAddress || (targetName.isNotEmpty() && name == targetName)) { if (address == targetAddress || (targetName.isNotEmpty() && name == targetName)) {
reconnectConnecting = true
logd { "Reconnect scan found: $name ($address)" } logd { "Reconnect scan found: $name ($address)" }
debugLogger.info("RECONNECT_FOUND $name ($address) RSSI=${result.rssi}") debugLogger.info("RECONNECT_FOUND $name ($address) RSSI=${result.rssi}")
try { scanner.stopScan(this) } catch (_: Exception) {} try { scanner.stopScan(this) } catch (_: Exception) {}
reconnectScanCallback = null reconnectScanCallback = null
// Connect directly (autoConnect=false for fast connection)
bluetoothGatt = result.device.connectGatt( bluetoothGatt = result.device.connectGatt(
context, false, gattCallback, BluetoothDevice.TRANSPORT_LE context, false, gattCallback, BluetoothDevice.TRANSPORT_LE
) )
@@ -735,14 +800,17 @@ class BleManager private constructor(private val context: Context) {
} }
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (characteristic.uuid == RX_CHAR_UUID) { if (characteristic.uuid == RX_CHAR_UUID) {
if (value.size < 4) return if (value.size < 4) return
handler.post { processReceivedData(value) } handler.post { processReceivedData(value) }
} }
} }
}
@Deprecated("Deprecated in API 33", ReplaceWith("onCharacteristicChanged(gatt, characteristic, value)")) @Deprecated("Deprecated in API 33", ReplaceWith("onCharacteristicChanged(gatt, characteristic, value)"))
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
if (characteristic.uuid == RX_CHAR_UUID) { if (characteristic.uuid == RX_CHAR_UUID) {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val data = characteristic.value ?: return val data = characteristic.value ?: return
@@ -750,6 +818,7 @@ class BleManager private constructor(private val context: Context) {
handler.post { processReceivedData(data) } handler.post { processReceivedData(data) }
} }
} }
}
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) { if (status == BluetoothGatt.GATT_SUCCESS) {
@@ -35,8 +35,8 @@ object PiezoHW {
var activePreset: DevicePreset = DevicePreset.V0 var activePreset: DevicePreset = DevicePreset.V0
fun autoDetectPreset(deviceName: String) { fun autoDetectPreset(deviceName: String) {
if (deviceName.startsWith("VBT2604") && deviceName.length >= 9) { if (deviceName.startsWith("VBT2604") && deviceName.length >= 10) {
when (deviceName[7]) { when (deviceName[8]) {
'0' -> activePreset = DevicePreset.V0 '0' -> activePreset = DevicePreset.V0
'2' -> activePreset = DevicePreset.V1 '2' -> activePreset = DevicePreset.V1
'3' -> activePreset = DevicePreset.V2 '3' -> activePreset = DevicePreset.V2
@@ -91,7 +91,7 @@ object PiezoHW {
val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray() val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray()
/** Acoustic calibration (런타임 조절 가능) */ /** Acoustic calibration (런타임 조절 가능) */
var distancePerSample: Double = 1.771 // mm/sample var distancePerSample: Double = 1.968 // mm/sample
const val delayOffsetMm: Double = 6.85 const val delayOffsetMm: Double = 6.85
/** Volume model */ /** Volume model */
@@ -17,7 +17,7 @@ data class LowEchoResult(
val lowStart: Int, // low-echo span 시작 val lowStart: Int, // low-echo span 시작
val lowEnd: Int, // low-echo span 끝 (inclusive) val lowEnd: Int, // low-echo span 끝 (inclusive)
val lowMean: Double, // low-echo 구간 평균 진폭 val lowMean: Double, // low-echo 구간 평균 진폭
val urineLen: Int, // post - ant - 1 val urineLen: Int, // post - ant
val score: Double, // low_depth * urine_len val score: Double, // low_depth * urine_len
val innerPeaks: List<Int> // (ant, post) 내부 local peak indices val innerPeaks: List<Int> // (ant, post) 내부 local peak indices
) { ) {
@@ -373,8 +373,9 @@ class PiezoEchoAnalyzer private constructor() {
private fun detectLowEchoCore(sg: DoubleArray, threshold: Double): LowEchoResult? { private fun detectLowEchoCore(sg: DoubleArray, threshold: Double): LowEchoResult? {
if (sg.size < 10) return null if (sg.size < 10) return null
// 1) low-echo span 추출 // 1) low-echo span 추출 (postMaxIdx까지만 — 이후는 노이즈)
val lowMask = BooleanArray(sg.size) { sg[it] <= threshold } val analysisLimit = min(sg.size, postMaxIdx + 1)
val lowMask = BooleanArray(sg.size) { it < analysisLimit && sg[it] <= threshold }
val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen } val rawSpans = contiguousTrueSpans(lowMask).filter { it.second - it.first + 1 >= lowMinLen }
// 2) 병합 — gap 내부에 벽 후보(threshold + 5 초과 peak)가 있으면 병합하지 않음 // 2) 병합 — gap 내부에 벽 후보(threshold + 5 초과 peak)가 있으면 병합하지 않음
@@ -383,7 +384,7 @@ class PiezoEchoAnalyzer private constructor() {
val firstSpan = spans.firstOrNull() ?: return null val firstSpan = spans.firstOrNull() ?: return null
val s = max(0, firstSpan.first) val s = max(0, firstSpan.first)
val e = min(sg.size - 1, firstSpan.second) val e = min(analysisLimit - 1, firstSpan.second)
if (s > e) return null if (s > e) return null
val lowSlice = safeSlice(sg, from = s, to = e) ?: return null val lowSlice = safeSlice(sg, from = s, to = e) ?: return null
@@ -397,20 +398,23 @@ class PiezoEchoAnalyzer private constructor() {
var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin, var post = selectWallByProminence(sg = sg, edge = e, searchWin = peakSearchWin,
peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null
// 4) post > POST_MAX_IDX: prominence 기반 재탐색 // 4) post가 분석 범위를 넘으면 재탐색
if (post > postMaxIdx) { if (post >= analysisLimit) {
val backHalfEdge = (s + e) / 2 val backHalfEdge = (s + e) / 2
post = selectWallByProminence( post = selectWallByProminence(
sg = sg, edge = backHalfEdge, sg = sg, edge = backHalfEdge,
searchWin = postMaxIdx - backHalfEdge, searchWin = analysisLimit - 1 - backHalfEdge,
peakMin = peakMin, side = WallSide.POST, otherEdge = null peakMin = peakMin, side = WallSide.POST, otherEdge = null
) ?: return null ) ?: return null
} }
val antH = sg[ant] val antH = sg[ant]
val postH = sg[post] val postH = sg[post]
if (postH < threshold) return null
val lowDepth = ((antH + postH) / 2.0) - lowMean val lowDepth = ((antH + postH) / 2.0) - lowMean
val urineLen = post - ant - 1 val urineLen = post - ant
if (urineLen < minUrineLen) return null if (urineLen < minUrineLen) return null
@@ -49,6 +49,7 @@ fun DeviceScanView(appState: AppState) {
.sortedByDescending { it.rssi } .sortedByDescending { it.rssi }
val isConnected by bleManager.isConnected val isConnected by bleManager.isConnected
val isServiceReady by bleManager.isServiceReady
val isScanning by bleManager.isScanning val isScanning by bleManager.isScanning
val bluetoothEnabled by bleManager.bluetoothEnabled val bluetoothEnabled by bleManager.bluetoothEnabled
val connectionError by bleManager.connectionError val connectionError by bleManager.connectionError
@@ -93,8 +94,8 @@ fun DeviceScanView(appState: AppState) {
} }
} }
LaunchedEffect(isConnected) { LaunchedEffect(isServiceReady) {
if (isConnected) { if (isServiceReady) {
connectingDeviceAddress = null connectingDeviceAddress = null
appState.deviceConnected() appState.deviceConnected()
} }
@@ -340,9 +341,9 @@ private fun DeviceRow(device: BleDevice, isConnecting: Boolean, enabled: Boolean
@Composable @Composable
private fun SignalBars(rssi: Int) { private fun SignalBars(rssi: Int) {
val strength = when { val strength = when {
rssi >= -65 -> 4 // Good+Excellent 통합 → Green rssi >= -80 -> 4 // Good
rssi >= -80 -> 2 rssi >= -90 -> 2 // Fair
else -> 1 else -> 1 // Weak
} }
val color = when { val color = when {
strength >= 4 -> Color.Green strength >= 4 -> Color.Green
@@ -363,8 +364,8 @@ private fun SignalBars(rssi: Int) {
} }
private fun signalLabel(rssi: Int): String = when { private fun signalLabel(rssi: Int): String = when {
rssi >= -65 -> "Good" rssi >= -80 -> "Good"
rssi >= -80 -> "Fair" rssi >= -90 -> "Fair"
else -> "Weak" else -> "Weak"
} }
@@ -8,9 +8,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import com.example.medilightv2android.ble.BleManager
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -25,12 +22,6 @@ import com.example.medilightv2android.ui.theme.*
@Composable @Composable
fun HomeView(appState: AppState) { fun HomeView(appState: AppState) {
val context = LocalContext.current
// 앱 시작 시 마지막 기기에 자동 연결 시도
LaunchedEffect(Unit) {
BleManager.getInstance(context).autoConnectLastDevice()
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -536,70 +536,42 @@ fun PiezoMonitoringView(appState: AppState) {
.padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp), .padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = { appState.goHome() }, modifier = Modifier.size(48.dp)) { val btnSize = if (isCompact) 36.dp else 44.dp
Icon(Icons.Default.Home, "Home", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp)) val iconSize = if (isCompact) 20.dp else 24.dp
} IconButton(onClick = { appState.currentScreen = AppScreen.PLACEMENT_GUIDE }, modifier = Modifier.size(btnSize)) {
IconButton(onClick = { appState.currentScreen = AppScreen.PLACEMENT_GUIDE }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.MyLocation, "Placement", tint = MlPrimary, modifier = Modifier.size(iconSize))
Icon(Icons.Default.MyLocation, "Placement", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
}
IconButton(onClick = { appState.currentScreen = AppScreen.VOIDING_DIARY }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.Description, stringResource(R.string.voiding_diary), tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
}
IconButton(onClick = { appState.currentScreen = AppScreen.REMINDER_SETTINGS }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.Notifications, contentDescription = "Reminder", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
}
if (!isCompact) {
Text("Bladder Monitor", fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp))
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
if (isAutoMeasuring) { // Battery icon (color by level)
if (!isDemoMode) {
val batteryColor = when {
batteryLvl >= 60 -> Color(0xFF4CAF50)
batteryLvl >= 30 -> Color(0xFFFF9800)
else -> Color.Red
}
val batteryIcon = when {
batteryLvl >= 60 -> Icons.Default.BatteryFull
batteryLvl >= 30 -> Icons.Default.Battery4Bar
else -> Icons.Default.Battery1Bar
}
Icon(batteryIcon, "Battery $batteryLvl%", tint = batteryColor, modifier = Modifier.size(iconSize))
}
// Connection dot
Box( Box(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(8.dp)) .size(8.dp)
.background(Color.Red.copy(alpha = 0.12f))
.padding(horizontal = 6.dp, vertical = 3.dp)
) {
Text(if (isCompact) "A" else "Auto", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Red)
}
Spacer(modifier = Modifier.width(4.dp))
}
// Catheter count
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(MlPrimary.copy(alpha = 0.1f))
.clickable { showAddCatheterDialog = true }
.padding(horizontal = 6.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Add, "Add catheter", tint = MlPrimary, modifier = Modifier.size(12.dp))
Text("$catheterCount", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Spacer(modifier = Modifier.width(4.dp))
IconButton(onClick = { appState.showHistory() }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.History, "History", tint = MlSecondaryText, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
}
IconButton(onClick = { showSettings = !showSettings }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.Settings, "Settings", tint = if (showSettings) MlPrimary else MlSecondaryText, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
}
// Battery / Demo
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 if (!isCompact) {
Text("$batteryLvl%", fontSize = 13.sp, color = MlSecondaryText)
}
Spacer(modifier = Modifier.width(4.dp))
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape) .clip(CircleShape)
.background(if (isDemoMode || isConnected) Color.Green else Color.Red) .background(if (isDemoMode || isConnected) Color(0xFF4CAF50) else Color.Red)
) )
Spacer(modifier = Modifier.width(4.dp))
// Settings button
IconButton(onClick = { showSettings = !showSettings }, modifier = Modifier.size(btnSize)) {
Icon(Icons.Default.Settings, "Settings", tint = if (showSettings) MlPrimary else MlSecondaryText, modifier = Modifier.size(iconSize))
}
} }
if (showDisconnectionBanner) { if (showDisconnectionBanner) {
@@ -725,6 +697,41 @@ fun PiezoMonitoringView(appState: AppState) {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text("${appState.piezoSettings.maxVolume} ml", fontSize = 14.sp, color = MlSecondaryText) Text("${appState.piezoSettings.maxVolume} ml", fontSize = 14.sp, color = MlSecondaryText)
} }
HorizontalDivider()
// Disconnect device
var showDisconnectConfirm by remember { mutableStateOf(false) }
Button(
onClick = { showDisconnectConfirm = true },
modifier = Modifier.fillMaxWidth().height(44.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.1f))
) {
Icon(Icons.Default.LinkOff, null, tint = Color.Red, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Disconnect Device", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.Red)
}
if (showDisconnectConfirm) {
AlertDialog(
onDismissRequest = { showDisconnectConfirm = false },
title = { Text("Disconnect Device") },
text = { Text("기기 연결을 해제하고 페어링 정보를 삭제합니다. 다시 연결하려면 기기 검색이 필요합니다.") },
confirmButton = {
TextButton(onClick = {
showDisconnectConfirm = false
showSettings = false
// Disconnect + remove bond + clear saved device
bleManager.disconnectAndUnbond()
appState.goHome()
}) { Text("Disconnect", color = Color.Red) }
},
dismissButton = {
TextButton(onClick = { showDisconnectConfirm = false }) { Text("Cancel") }
}
)
}
} }
} }
} }
@@ -776,67 +783,53 @@ fun PiezoMonitoringView(appState: AppState) {
InfoCard("Level", "${appState.bladderLevel.currentLevel}/${BladderLevel.MAX_LEVEL}", "", urgency.accentColor, Modifier.weight(1f)) InfoCard("Level", "${appState.bladderLevel.currentLevel}/${BladderLevel.MAX_LEVEL}", "", urgency.accentColor, Modifier.weight(1f))
} }
// Control buttons // Control buttons: Catheterize / Auto / Spot
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Catheterize button → 배뇨 기록 시트 // Catheterize
Button( Button(
onClick = { showCatheterizeSheet = true }, onClick = { showCatheterizeSheet = true },
modifier = Modifier.weight(1f).height(56.dp), modifier = Modifier.weight(1.2f).height(52.dp),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.1f)) colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.1f))
) { ) {
Text("Catheterize", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlPrimary) Text("Catheterize", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlPrimary)
} }
// Spot button (tap = single, long press = auto 30s) // Auto
Box( Button(
modifier = Modifier onClick = {
.weight(1f) if (isAutoMeasuring) stopAutoMeasure() else startAutoMeasure()
.height(56.dp)
.clip(RoundedCornerShape(16.dp))
.background(
Brush.horizontalGradient(
if (isAutoMeasuring) listOf(Color.Red, Color(0xFFFF9500))
else urgency.gradientColors
)
)
.pointerInput(isAutoMeasuring, isMeasuring) {
detectTapGestures(
onTap = {
if (isAutoMeasuring) stopAutoMeasure()
else if (!isMeasuring) measure()
}, },
onLongPress = { modifier = Modifier.weight(1f).height(52.dp),
if (!isAutoMeasuring && !isMeasuring) startAutoMeasure() shape = RoundedCornerShape(14.dp),
} colors = ButtonDefaults.buttonColors(
containerColor = if (isAutoMeasuring) Color.Red else Color(0xFFFF9800)
) )
},
contentAlignment = Alignment.Center
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { if (isAutoMeasuring) {
if (isMeasuring && !isAutoMeasuring) { Text("Stop", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp), strokeWidth = 2.dp) } else {
} Text("Auto", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.width(8.dp))
Text(
when {
isAutoMeasuring -> "Stop"
isMeasuring -> "Measuring..."
else -> "Spot"
},
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
} }
} }
Text( // Spot (single measurement)
if (isAutoMeasuring) "Auto: IMU 1s + Measure 10s (#$autoTickCount)" else "Long press for auto (IMU 1s + measure 10s)", Button(
fontSize = 11.sp, onClick = { if (!isMeasuring && !isAutoMeasuring) measure() },
color = if (isAutoMeasuring) Color.Red.copy(alpha = 0.7f) else MlSecondaryText enabled = !isMeasuring && !isAutoMeasuring,
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = urgency.gradientColors.first()
) )
) {
if (isMeasuring && !isAutoMeasuring) {
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text("Spot", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
}
}
}
// ── Developer Tools toggle ── // ── Developer Tools toggle ──
Button( Button(
@@ -294,14 +294,9 @@ fun PlacementGuideView(appState: AppState) {
// 첫 스캔 시 1회만 piezo power on // 첫 스캔 시 1회만 piezo power on
var piezoPowered by remember { mutableStateOf(false) } var piezoPowered by remember { mutableStateOf(false) }
val handler = remember { android.os.Handler(android.os.Looper.getMainLooper()) }
fun performScan() { fun sendMeasureCommand() {
if (isScanning) return
isScanning = true
if (!piezoPowered) {
bleManager.sendPiezoPowerOn()
piezoPowered = true
}
bleManager.piezoCollector.onMultiChannelComplete = { channels -> bleManager.piezoCollector.onMultiChannelComplete = { channels ->
isScanning = false isScanning = false
scanCount++ scanCount++
@@ -319,13 +314,6 @@ fun PlacementGuideView(appState: AppState) {
detected[ch.channel] = true detected[ch.channel] = true
urineLens[ch.channel] = analysis.result.urineLen urineLens[ch.channel] = analysis.result.urineLen
allWalls[ch.channel] = Pair(analysis.result.ant, analysis.result.post) allWalls[ch.channel] = Pair(analysis.result.ant, analysis.result.post)
} else {
val gate = UrinAI.detect(ch.buffer)
if (gate.detected && (gate.bw - gate.fw) >= MIN_URINE_LEN_PLACEMENT) {
detected[ch.channel] = true
urineLens[ch.channel] = gate.bw - gate.fw
allWalls[ch.channel] = Pair(gate.fw, gate.bw)
}
} }
} }
} }
@@ -377,7 +365,19 @@ fun PlacementGuideView(appState: AppState) {
bleManager.sendAllChannels() bleManager.sendAllChannels()
} }
// Continuous scan loop (3초 간격) — LED는 스캔 콜백에서 동적 제어 fun performScan() {
if (isScanning) return
isScanning = true
if (!piezoPowered) {
bleManager.sendPiezoPowerOn()
piezoPowered = true
handler.postDelayed({ sendMeasureCommand() }, 500)
} else {
sendMeasureCommand()
}
}
// Continuous scan loop (1초 간격) — LED는 스캔 콜백에서 동적 제어
LaunchedEffect(isContinuousMode) { LaunchedEffect(isContinuousMode) {
if (isContinuousMode) { if (isContinuousMode) {
performScan() performScan()
@@ -464,11 +464,21 @@ fun PlacementGuideView(appState: AppState) {
} }
// Skip button // Skip button
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(12.dp))
TextButton(onClick = { appState.placementComplete() }) { Button(
onClick = { appState.placementComplete() },
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MlSecondaryText.copy(alpha = 0.15f)
)
) {
Text( Text(
text = "Skip Placement Guide", text = "Skip",
fontSize = 13.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = MlSecondaryText color = MlSecondaryText
) )
} }
@@ -27,8 +27,7 @@ import com.example.medilightv2android.ui.theme.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private val pages = listOf( private val pages = listOf(
"Welcome to MedilightV2" to "Smart bladder monitoring\nfor better quality of life", "Welcome to VesiScan-Basic" to "Smart bladder monitoring\nfor better quality of life",
"NIRS & Piezo Sensors" to "Advanced dual-sensor technology\nfor accurate measurements",
"Real-time Monitoring" to "Track your bladder level\nwith instant visual feedback" "Real-time Monitoring" to "Track your bladder level\nwith instant visual feedback"
) )
@@ -62,28 +62,6 @@ fun PiezoPersonalizationView(appState: AppState) {
IconButton(onClick = { appState.backToSensorSelect() }) { IconButton(onClick = { appState.backToSensorSelect() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary) Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
} }
Spacer(modifier = Modifier.weight(1f))
Text("Step $currentStep of $totalSteps", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(20.dp))
}
// Progress bar
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.height(6.dp)
.clip(RoundedCornerShape(4.dp))
.background(MlPrimary.copy(alpha = 0.15f))
) {
Box(
modifier = Modifier
.fillMaxWidth(currentStep.toFloat() / totalSteps)
.fillMaxHeight()
.clip(RoundedCornerShape(4.dp))
.background(Brush.horizontalGradient(listOf(MlTeal, MlPrimary)))
)
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -173,8 +151,12 @@ private fun MaxVolumeStep(appState: AppState) {
Text("Set your maximum bladder capacity", fontSize = 14.sp, color = MlSecondaryText) Text("Set your maximum bladder capacity", fontSize = 14.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text("${appState.piezoSettings.maxVolume}", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = MlPrimary) Text("${appState.piezoSettings.maxVolume}", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
Text("ml", fontSize = 20.sp, color = MlSecondaryText) Spacer(modifier = Modifier.width(8.dp))
Text("mL", fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText,
modifier = Modifier.padding(bottom = 10.dp))
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))