From 5cdae8a86eece08e3d1a1f70b612c84a72c5a43a Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 28 Apr 2026 17:03:37 +0900 Subject: [PATCH] =?UTF-8?q?fix+UX:=20BLE=20=EC=A4=91=EB=B3=B5=EC=88=98?= =?UTF-8?q?=EC=8B=A0=20=EC=88=98=EC=A0=95,=20peak=20detection=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0,=20UI=20=EB=8C=80=ED=8F=AD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../example/medilightv2android/AppState.kt | 10 +- .../medilightv2android/MainActivity.kt | 18 +- .../medilightv2android/ble/BleManager.kt | 89 +++++++- .../managers/PiezoBVEstimator.kt | 6 +- .../managers/PiezoEchoAnalyzer.kt | 20 +- .../ui/views/connection/DeviceScanView.kt | 15 +- .../ui/views/home/HomeView.kt | 9 - .../views/monitoring/PiezoMonitoringView.kt | 203 +++++++++--------- .../ui/views/monitoring/PlacementGuideView.kt | 48 +++-- .../ui/views/onboarding/OnboardingView.kt | 3 +- .../PiezoPersonalizationView.kt | 30 +-- 11 files changed, 254 insertions(+), 197 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/AppState.kt b/app/src/main/java/com/example/medilightv2android/AppState.kt index 77b02b5..35faa95 100644 --- a/app/src/main/java/com/example/medilightv2android/AppState.kt +++ b/app/src/main/java/com/example/medilightv2android/AppState.kt @@ -49,7 +49,6 @@ class AppState(private val context: Context) { currentScreen = when { !storage.hasCompletedOnboarding -> AppScreen.ONBOARDING - !storage.hasConsented -> AppScreen.CONSENT !storage.isRegistered -> AppScreen.REGISTRATION !storage.hasPin -> AppScreen.PIN_SETUP else -> AppScreen.PIN_ENTRY @@ -58,7 +57,8 @@ class AppState(private val context: Context) { fun completeOnboarding() { storage.hasCompletedOnboarding = true - currentScreen = AppScreen.CONSENT + storage.hasConsented = true + currentScreen = AppScreen.REGISTRATION } fun completeConsent() { @@ -93,7 +93,9 @@ class AppState(private val context: Context) { } fun startFromHome() { - currentScreen = AppScreen.SENSOR_SELECT + sensorMode = SensorMode.PIEZO + storage.sensorMode = SensorMode.PIEZO + currentScreen = AppScreen.DEVICE_SCAN } fun selectSensor(mode: SensorMode) { @@ -103,7 +105,7 @@ class AppState(private val context: Context) { } fun backToSensorSelect() { - currentScreen = AppScreen.SENSOR_SELECT + currentScreen = AppScreen.HOME } fun deviceConnected() { diff --git a/app/src/main/java/com/example/medilightv2android/MainActivity.kt b/app/src/main/java/com/example/medilightv2android/MainActivity.kt index 89e88fa..39a4d24 100644 --- a/app/src/main/java/com/example/medilightv2android/MainActivity.kt +++ b/app/src/main/java/com/example/medilightv2android/MainActivity.kt @@ -61,19 +61,25 @@ fun MedilightApp(modifier: Modifier = Modifier) { val context = LocalContext.current val appState = remember { AppState(context) } - // Android 뒤로가기 = 앱 내 뒤로가기 - BackHandler(enabled = appState.currentScreen != AppScreen.HOME) { + // Android 뒤로가기 = 바로 전 화면 + BackHandler(enabled = appState.currentScreen != AppScreen.HOME + && appState.currentScreen != AppScreen.PIN_ENTRY) { when (appState.currentScreen) { - AppScreen.SENSOR_SELECT -> appState.currentScreen = AppScreen.HOME - AppScreen.DEVICE_SCAN -> appState.currentScreen = AppScreen.SENSOR_SELECT + // 메인 플로우: Home → DeviceScan → Personalization → Placement → Monitoring + AppScreen.DEVICE_SCAN -> appState.currentScreen = AppScreen.HOME 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.VIVAMYO_MONITORING -> appState.goHome() + // 모니터링 하위 화면 → 모니터링 AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING AppScreen.REMINDER_SETTINGS -> 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 } } diff --git a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt index 91a3077..a7548db 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt @@ -166,6 +166,12 @@ class BleManager private constructor(private val context: Context) { // Connection fun connect(bleDevice: BleDevice) { stopScan() + cancelAutoReconnect() + reconnectScanCallback?.let { cb -> + try { bluetoothAdapter?.bluetoothLeScanner?.stopScan(cb) } catch (_: Exception) {} + reconnectScanCallback = null + } + reconnectConnecting = false logd { "Connecting to ${bleDevice.name} (${bleDevice.address})" } // Clean up any existing GATT connection @@ -241,6 +247,55 @@ class BleManager private constructor(private val context: Context) { 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 fun reconnectToLastDevice(lastDeviceAddress: String?) { if (lastDeviceAddress == null || bluetoothAdapter?.isEnabled != true) { @@ -248,6 +303,13 @@ class BleManager private constructor(private val context: Context) { return } + // Stop any active reconnect scan first + reconnectScanCallback?.let { cb -> + try { bluetoothAdapter.bluetoothLeScanner?.stopScan(cb) } catch (_: Exception) {} + reconnectScanCallback = null + } + reconnectConnecting = false + // Clean up existing bluetoothGatt?.let { gatt -> try { gatt.disconnect(); gatt.close() } catch (_: Exception) {} @@ -481,6 +543,7 @@ class BleManager private constructor(private val context: Context) { } private var reconnectScanCallback: ScanCallback? = null + @Volatile private var reconnectConnecting = false private fun attemptAutoReconnect() { val targetAddress = lastConnectedAddress ?: return @@ -492,11 +555,12 @@ class BleManager private constructor(private val context: Context) { } reconnectAttempt++ + reconnectConnecting = false logd { "Auto-reconnect #$reconnectAttempt: scanning for $targetName ($targetAddress)" } debugLogger.info("RECONNECT_SCAN #$reconnectAttempt scanning for $targetName") bluetoothGatt?.let { gatt -> - try { gatt.close() } catch (_: Exception) {} + try { gatt.disconnect(); gatt.close() } catch (_: Exception) {} bluetoothGatt = null } @@ -514,17 +578,18 @@ class BleManager private constructor(private val context: Context) { val callback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { + if (reconnectConnecting) return val name = try { result.device.name ?: "" } catch (_: Exception) { "" } val address = result.device.address if (address == targetAddress || (targetName.isNotEmpty() && name == targetName)) { + reconnectConnecting = true logd { "Reconnect scan found: $name ($address)" } debugLogger.info("RECONNECT_FOUND $name ($address) RSSI=${result.rssi}") try { scanner.stopScan(this) } catch (_: Exception) {} reconnectScanCallback = null - // Connect directly (autoConnect=false for fast connection) bluetoothGatt = result.device.connectGatt( context, false, gattCallback, BluetoothDevice.TRANSPORT_LE ) @@ -735,19 +800,23 @@ class BleManager private constructor(private val context: Context) { } override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { - if (characteristic.uuid == RX_CHAR_UUID) { - if (value.size < 4) return - handler.post { processReceivedData(value) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (characteristic.uuid == RX_CHAR_UUID) { + if (value.size < 4) return + handler.post { processReceivedData(value) } + } } } @Deprecated("Deprecated in API 33", ReplaceWith("onCharacteristicChanged(gatt, characteristic, value)")) override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { - if (characteristic.uuid == RX_CHAR_UUID) { - @Suppress("DEPRECATION") - val data = characteristic.value ?: return - if (data.size < 4) return - handler.post { processReceivedData(data) } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + if (characteristic.uuid == RX_CHAR_UUID) { + @Suppress("DEPRECATION") + val data = characteristic.value ?: return + if (data.size < 4) return + handler.post { processReceivedData(data) } + } } } diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt index ee7ea5a..caf4033 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt @@ -35,8 +35,8 @@ object PiezoHW { var activePreset: DevicePreset = DevicePreset.V0 fun autoDetectPreset(deviceName: String) { - if (deviceName.startsWith("VBT2604") && deviceName.length >= 9) { - when (deviceName[7]) { + if (deviceName.startsWith("VBT2604") && deviceName.length >= 10) { + when (deviceName[8]) { '0' -> activePreset = DevicePreset.V0 '2' -> activePreset = DevicePreset.V1 '3' -> activePreset = DevicePreset.V2 @@ -91,7 +91,7 @@ object PiezoHW { val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray() /** Acoustic calibration (런타임 조절 가능) */ - var distancePerSample: Double = 1.771 // mm/sample + var distancePerSample: Double = 1.968 // mm/sample const val delayOffsetMm: Double = 6.85 /** Volume model */ diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt index c006927..f797576 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoEchoAnalyzer.kt @@ -17,7 +17,7 @@ data class LowEchoResult( val lowStart: Int, // low-echo span 시작 val lowEnd: Int, // low-echo span 끝 (inclusive) val lowMean: Double, // low-echo 구간 평균 진폭 - val urineLen: Int, // post - ant - 1 + val urineLen: Int, // post - ant val score: Double, // low_depth * urine_len val innerPeaks: List // (ant, post) 내부 local peak indices ) { @@ -373,8 +373,9 @@ class PiezoEchoAnalyzer private constructor() { private fun detectLowEchoCore(sg: DoubleArray, threshold: Double): LowEchoResult? { if (sg.size < 10) return null - // 1) low-echo span 추출 - val lowMask = BooleanArray(sg.size) { sg[it] <= threshold } + // 1) low-echo span 추출 (postMaxIdx까지만 — 이후는 노이즈) + 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 } // 2) 병합 — gap 내부에 벽 후보(threshold + 5 초과 peak)가 있으면 병합하지 않음 @@ -383,7 +384,7 @@ class PiezoEchoAnalyzer private constructor() { val firstSpan = spans.firstOrNull() ?: return null 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 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, peakMin = peakMin, side = WallSide.POST, otherEdge = s) ?: return null - // 4) post > POST_MAX_IDX: prominence 기반 재탐색 - if (post > postMaxIdx) { + // 4) post가 분석 범위를 넘으면 재탐색 + if (post >= analysisLimit) { val backHalfEdge = (s + e) / 2 post = selectWallByProminence( sg = sg, edge = backHalfEdge, - searchWin = postMaxIdx - backHalfEdge, + searchWin = analysisLimit - 1 - backHalfEdge, peakMin = peakMin, side = WallSide.POST, otherEdge = null ) ?: return null } val antH = sg[ant] val postH = sg[post] + + if (postH < threshold) return null + val lowDepth = ((antH + postH) / 2.0) - lowMean - val urineLen = post - ant - 1 + val urineLen = post - ant if (urineLen < minUrineLen) return null diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt index 1f8af3a..7b1c101 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt @@ -49,6 +49,7 @@ fun DeviceScanView(appState: AppState) { .sortedByDescending { it.rssi } val isConnected by bleManager.isConnected + val isServiceReady by bleManager.isServiceReady val isScanning by bleManager.isScanning val bluetoothEnabled by bleManager.bluetoothEnabled val connectionError by bleManager.connectionError @@ -93,8 +94,8 @@ fun DeviceScanView(appState: AppState) { } } - LaunchedEffect(isConnected) { - if (isConnected) { + LaunchedEffect(isServiceReady) { + if (isServiceReady) { connectingDeviceAddress = null appState.deviceConnected() } @@ -340,9 +341,9 @@ private fun DeviceRow(device: BleDevice, isConnecting: Boolean, enabled: Boolean @Composable private fun SignalBars(rssi: Int) { val strength = when { - rssi >= -65 -> 4 // Good+Excellent 통합 → Green - rssi >= -80 -> 2 - else -> 1 + rssi >= -80 -> 4 // Good + rssi >= -90 -> 2 // Fair + else -> 1 // Weak } val color = when { strength >= 4 -> Color.Green @@ -363,8 +364,8 @@ private fun SignalBars(rssi: Int) { } private fun signalLabel(rssi: Int): String = when { - rssi >= -65 -> "Good" - rssi >= -80 -> "Fair" + rssi >= -80 -> "Good" + rssi >= -90 -> "Fair" else -> "Weak" } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt index 0087e92..87dcdac 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt @@ -8,9 +8,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text 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.Modifier import androidx.compose.ui.draw.clip @@ -25,12 +22,6 @@ import com.example.medilightv2android.ui.theme.* @Composable fun HomeView(appState: AppState) { - val context = LocalContext.current - // 앱 시작 시 마지막 기기에 자동 연결 시도 - LaunchedEffect(Unit) { - BleManager.getInstance(context).autoConnectLastDevice() - } - Column( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index b862434..f5d9b1a 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -536,70 +536,42 @@ fun PiezoMonitoringView(appState: AppState) { .padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp), verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = { appState.goHome() }, modifier = Modifier.size(48.dp)) { - Icon(Icons.Default.Home, "Home", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp)) - } - IconButton(onClick = { appState.currentScreen = AppScreen.PLACEMENT_GUIDE }, modifier = Modifier.size(48.dp)) { - 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)) + val btnSize = if (isCompact) 36.dp else 44.dp + val iconSize = if (isCompact) 20.dp else 24.dp + IconButton(onClick = { appState.currentScreen = AppScreen.PLACEMENT_GUIDE }, modifier = Modifier.size(btnSize)) { + Icon(Icons.Default.MyLocation, "Placement", tint = MlPrimary, modifier = Modifier.size(iconSize)) } Spacer(modifier = Modifier.weight(1f)) - if (isAutoMeasuring) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(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) + // Battery icon (color by level) + if (!isDemoMode) { + val batteryColor = when { + batteryLvl >= 60 -> Color(0xFF4CAF50) + batteryLvl >= 30 -> Color(0xFFFF9800) + else -> Color.Red } - Spacer(modifier = Modifier.width(4.dp)) + 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)) } - // 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)) + // Connection dot Box( modifier = Modifier - .size(10.dp) + .size(8.dp) .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) { @@ -725,6 +697,41 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(modifier = Modifier.weight(1f)) 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,68 +783,54 @@ fun PiezoMonitoringView(appState: AppState) { InfoCard("Level", "${appState.bladderLevel.currentLevel}/${BladderLevel.MAX_LEVEL}", "", urgency.accentColor, Modifier.weight(1f)) } - // Control buttons - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - // Catheterize button → 배뇨 기록 시트 + // Control buttons: Catheterize / Auto / Spot + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // Catheterize Button( onClick = { showCatheterizeSheet = true }, - modifier = Modifier.weight(1f).height(56.dp), - shape = RoundedCornerShape(16.dp), + modifier = Modifier.weight(1.2f).height(52.dp), + shape = RoundedCornerShape(14.dp), 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) - Box( - modifier = Modifier - .weight(1f) - .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 = { - if (!isAutoMeasuring && !isMeasuring) startAutoMeasure() - } - ) - }, - contentAlignment = Alignment.Center + // Auto + Button( + onClick = { + if (isAutoMeasuring) stopAutoMeasure() else startAutoMeasure() + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isAutoMeasuring) Color.Red else Color(0xFFFF9800) + ) ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (isMeasuring && !isAutoMeasuring) { - CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - } - Spacer(modifier = Modifier.width(8.dp)) - Text( - when { - isAutoMeasuring -> "Stop" - isMeasuring -> "Measuring..." - else -> "Spot" - }, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White - ) + if (isAutoMeasuring) { + Text("Stop", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = Color.White) + } else { + Text("Auto", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = Color.White) + } + } + + // Spot (single measurement) + Button( + onClick = { if (!isMeasuring && !isAutoMeasuring) measure() }, + 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) } } } - Text( - if (isAutoMeasuring) "Auto: IMU 1s + Measure 10s (#$autoTickCount)" else "Long press for auto (IMU 1s + measure 10s)", - fontSize = 11.sp, - color = if (isAutoMeasuring) Color.Red.copy(alpha = 0.7f) else MlSecondaryText - ) - // ── Developer Tools toggle ── Button( onClick = { showDevPanels = !showDevPanels }, diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index f7c9adb..38e6b79 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -294,14 +294,9 @@ fun PlacementGuideView(appState: AppState) { // 첫 스캔 시 1회만 piezo power on var piezoPowered by remember { mutableStateOf(false) } + val handler = remember { android.os.Handler(android.os.Looper.getMainLooper()) } - fun performScan() { - if (isScanning) return - isScanning = true - if (!piezoPowered) { - bleManager.sendPiezoPowerOn() - piezoPowered = true - } + fun sendMeasureCommand() { bleManager.piezoCollector.onMultiChannelComplete = { channels -> isScanning = false scanCount++ @@ -319,13 +314,6 @@ fun PlacementGuideView(appState: AppState) { detected[ch.channel] = true urineLens[ch.channel] = analysis.result.urineLen 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() } - // 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) { if (isContinuousMode) { performScan() @@ -464,11 +464,21 @@ fun PlacementGuideView(appState: AppState) { } // Skip button - Spacer(modifier = Modifier.height(16.dp)) - TextButton(onClick = { appState.placementComplete() }) { + Spacer(modifier = Modifier.height(12.dp)) + 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 = "Skip Placement Guide", - fontSize = 13.sp, + text = "Skip", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, color = MlSecondaryText ) } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/onboarding/OnboardingView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/onboarding/OnboardingView.kt index 697e992..5e1baab 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/onboarding/OnboardingView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/onboarding/OnboardingView.kt @@ -27,8 +27,7 @@ import com.example.medilightv2android.ui.theme.* import kotlinx.coroutines.launch private val pages = listOf( - "Welcome to MedilightV2" to "Smart bladder monitoring\nfor better quality of life", - "NIRS & Piezo Sensors" to "Advanced dual-sensor technology\nfor accurate measurements", + "Welcome to VesiScan-Basic" to "Smart bladder monitoring\nfor better quality of life", "Real-time Monitoring" to "Track your bladder level\nwith instant visual feedback" ) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/personalization/PiezoPersonalizationView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/personalization/PiezoPersonalizationView.kt index c063985..3ec11dd 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/personalization/PiezoPersonalizationView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/personalization/PiezoPersonalizationView.kt @@ -62,28 +62,6 @@ fun PiezoPersonalizationView(appState: AppState) { IconButton(onClick = { appState.backToSensorSelect() }) { 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)) @@ -173,8 +151,12 @@ private fun MaxVolumeStep(appState: AppState) { Text("Set your maximum bladder capacity", fontSize = 14.sp, color = MlSecondaryText) Spacer(modifier = Modifier.height(24.dp)) - Text("${appState.piezoSettings.maxVolume}", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = MlPrimary) - Text("ml", fontSize = 20.sp, color = MlSecondaryText) + Row(verticalAlignment = Alignment.Bottom) { + Text("${appState.piezoSettings.maxVolume}", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = MlPrimary) + 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))