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:
@@ -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
|
||||
|
||||
|
||||
+8
-7
@@ -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()
|
||||
|
||||
+98
-105
@@ -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 },
|
||||
|
||||
+29
-19
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+1
-2
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
+6
-24
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user