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 {
!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() {
@@ -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
}
}
@@ -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) }
}
}
}
@@ -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 */
@@ -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<Int> // (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
@@ -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"
}
@@ -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()
@@ -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 },
@@ -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
)
}
@@ -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"
)
@@ -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))