Clinical UI: BLE Connecting 상태 + Notes IME Done + 6채널 그래프 키움

3건 동시 적용:

1. ClinicalHome 'BLE Connecting…' 상태 표시
   증상: Unpair 후 새 기기 페어링 시 처음 3~5초간 'BLE Not Connected' 표시.
   원인: connectGatt 호출부터 STATE_CONNECTED까지 시간 걸리는데 그 사이 isConnected==false.
   기존 isReconnecting state는 reconnect 경로 전용이라 새 페어링엔 안 잡힘.
   수정:
   - BleManager.isConnecting state 신규 추가 (mutableStateOf)
   - connectGatt 호출 직전 true, STATE_CONNECTED/DISCONNECTED 시 false
   - 예외 catch에서도 false (실패 시 stuck 방지)
   - ClinicalHome에서 isConnected==false && (isConnecting || isReconnecting)일 때
     CircularProgressIndicator + 'BLE Connecting…' 텍스트 표시

2. ClinicalHome Notes 필드 IME Done
   기존: 여러 줄 입력 가능했지만 키보드 ✓ 버튼이 없어 닫기 어려움.
   수정: singleLine=true + imeAction=Done + onDone=hide keyboard
   Examiner 필드와 동일한 동작.

3. ClinicalLive 6채널 그래프 키움 (2x3 grid 유지)
   회의 결정: layout 그대로, 셀 크기/폰트/선 굵기 모두 키움.
   - Card height: 120dp → 240dp (2배)
   - Card padding: 4dp → 8dp / corner: 6 → 8
   - 색 box: 8dp → 12dp
   - CH label: 10sp → 16sp
   - ant/post label: 9sp → 14sp
   - peak label: 9sp → 14sp + SemiBold
   - waveform stroke: 1.5 → 2.5
   - ant/post 마커 stroke: 1.5 → 2.5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 14:33:37 +09:00
parent d2e7640292
commit 5703355fc4
3 changed files with 49 additions and 18 deletions
@@ -88,6 +88,9 @@ class BleManager private constructor(private val context: Context) {
@android.annotation.SuppressLint("MissingPermission")
get() = bluetoothGatt?.device?.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED
val isReconnecting = mutableStateOf(false)
/** 새 페어링/재연결 진행 중 (connectGatt 호출 ~ STATE_CONNECTED 또는 실패까지).
* ClinicalHome 등에서 "BLE Connecting…" 표시용. */
val isConnecting = mutableStateOf(false)
val connectionError = mutableStateOf<String?>(null)
// Callbacks
@@ -206,12 +209,14 @@ class BleManager private constructor(private val context: Context) {
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = true)
connectionError.value = null
isConnecting.value = true
try {
bluetoothGatt = bleDevice.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
logd { "connectGatt called, gatt=${bluetoothGatt != null}" }
} catch (e: Exception) {
loge { "connectGatt failed: ${e.message}" }
isConnecting.value = false
handler.post {
connectionError.value = "Connection failed: ${e.message}"
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
@@ -786,6 +791,7 @@ class BleManager private constructor(private val context: Context) {
BluetoothProfile.STATE_CONNECTED -> {
logd { "Connected to ${gatt.device.address}" }
isReconnecting.value = false
isConnecting.value = false
isUserDisconnect = false
connectionError.value = null
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
@@ -819,6 +825,7 @@ class BleManager private constructor(private val context: Context) {
}
BluetoothProfile.STATE_DISCONNECTED -> {
logd { "Disconnected from ${gatt.device.address}" }
isConnecting.value = false
stopBatteryPolling()
bluetoothGatt = null
txCharacteristic = null
@@ -135,6 +135,11 @@ fun ClinicalHomeView(appState: AppState) {
else Color(0xFFFF9800).copy(alpha = 0.15f)
)
) {
// 재연결/새 페어링 진행 중 — connectGatt 호출부터 STATE_CONNECTED까지 1~5초 걸리는 동안
// "BLE Not Connected"가 보이는 깜박임 방지.
val isConnecting by bleManager.isConnecting
val isReconnecting by bleManager.isReconnecting
val showConnecting = !isConnected && (isConnecting || isReconnecting)
Row(
modifier = Modifier.padding(12.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
@@ -159,6 +164,15 @@ fun ClinicalHomeView(appState: AppState) {
onClick = { showUnpairConfirm = true },
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFE53935))
) { Text("Unpair", fontSize = 12.sp) }
} else if (showConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MlPrimary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("BLE Connecting…", fontWeight = FontWeight.Bold, fontSize = 14.sp,
color = MlPrimary, modifier = Modifier.weight(1f))
} else {
Text("BLE Not Connected", fontWeight = FontWeight.Bold, fontSize = 14.sp,
color = Color(0xFFFF9800), modifier = Modifier.weight(1f))
@@ -403,11 +417,20 @@ fun ClinicalHomeView(appState: AppState) {
)
Spacer(modifier = Modifier.height(8.dp))
// Notes — Examiner처럼 키보드 우하단 ✓(Done)으로 완료 가능
val notesFocus = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes (선택)") },
modifier = Modifier.fillMaxWidth().height(80.dp)
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Done
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onDone = { notesFocus?.hide() }
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(20.dp))
@@ -374,34 +374,35 @@ private fun ChannelChart(
) {
val antColor = Color(0xFF4CAF50) // green = anterior wall
val postColor = Color(0xFFFF9800) // orange = posterior wall
// 사용자 피드백: 태블릿 2x3 grid에서 그래프 작음 → 셀 크기 + 폰트 + 선 굵기 모두 키움
Card(
shape = RoundedCornerShape(6.dp),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
modifier = Modifier.fillMaxWidth().height(120.dp)
modifier = Modifier.fillMaxWidth().height(240.dp)
) {
Column(modifier = Modifier.fillMaxSize().padding(4.dp)) {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(2.dp))
.size(12.dp)
.clip(RoundedCornerShape(3.dp))
.background(color)
)
Spacer(modifier = Modifier.width(4.dp))
Text("CH$channelIdx", fontSize = 10.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.width(6.dp))
Text("CH$channelIdx", fontSize = 16.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.weight(1f))
if (walls != null) {
Text("a${walls.first}", fontSize = 9.sp, color = antColor, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(3.dp))
Text("p${walls.second}", fontSize = 9.sp, color = postColor, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(4.dp))
Text("a${walls.first}", fontSize = 14.sp, color = antColor, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(6.dp))
Text("p${walls.second}", fontSize = 14.sp, color = postColor, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(8.dp))
}
if (buffer.isNotEmpty()) {
val peak = buffer.maxOrNull()?.toInt() ?: 0
Text("$peak", fontSize = 9.sp, color = MlSecondaryText)
Text("$peak", fontSize = 14.sp, color = MlSecondaryText, fontWeight = FontWeight.SemiBold)
}
}
Spacer(modifier = Modifier.height(2.dp))
Spacer(modifier = Modifier.height(4.dp))
Canvas(modifier = Modifier.fillMaxSize()) {
if (buffer.isEmpty()) return@Canvas
val w = size.width
@@ -429,22 +430,22 @@ private fun ChannelChart(
val (antIdx, postIdx) = walls
if (antIdx in 0 until n) {
val x = antIdx * xScale
drawLine(antColor.copy(alpha = 0.75f), Offset(x, 0f), Offset(x, h), strokeWidth = 1.5f)
drawLine(antColor.copy(alpha = 0.75f), Offset(x, 0f), Offset(x, h), strokeWidth = 2.5f)
}
if (postIdx in 0 until n) {
val x = postIdx * xScale
drawLine(postColor.copy(alpha = 0.75f), Offset(x, 0f), Offset(x, h), strokeWidth = 1.5f)
drawLine(postColor.copy(alpha = 0.75f), Offset(x, 0f), Offset(x, h), strokeWidth = 2.5f)
}
}
// waveform
// waveform — 굵게 (1.5 → 2.5)
val path = Path()
for (i in buffer.indices) {
val x = i * xScale
val y = h - (buffer[i].toInt() - yMin) * yScale
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(path, color, style = Stroke(width = 1.5f))
drawPath(path, color, style = Stroke(width = 2.5f))
}
}
}