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:
@@ -88,6 +88,9 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
@android.annotation.SuppressLint("MissingPermission")
|
@android.annotation.SuppressLint("MissingPermission")
|
||||||
get() = bluetoothGatt?.device?.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED
|
get() = bluetoothGatt?.device?.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED
|
||||||
val isReconnecting = mutableStateOf(false)
|
val isReconnecting = mutableStateOf(false)
|
||||||
|
/** 새 페어링/재연결 진행 중 (connectGatt 호출 ~ STATE_CONNECTED 또는 실패까지).
|
||||||
|
* ClinicalHome 등에서 "BLE Connecting…" 표시용. */
|
||||||
|
val isConnecting = mutableStateOf(false)
|
||||||
val connectionError = mutableStateOf<String?>(null)
|
val connectionError = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
@@ -206,12 +209,14 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = true)
|
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = true)
|
||||||
|
|
||||||
connectionError.value = null
|
connectionError.value = null
|
||||||
|
isConnecting.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bluetoothGatt = bleDevice.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
bluetoothGatt = bleDevice.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||||
logd { "connectGatt called, gatt=${bluetoothGatt != null}" }
|
logd { "connectGatt called, gatt=${bluetoothGatt != null}" }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
loge { "connectGatt failed: ${e.message}" }
|
loge { "connectGatt failed: ${e.message}" }
|
||||||
|
isConnecting.value = false
|
||||||
handler.post {
|
handler.post {
|
||||||
connectionError.value = "Connection failed: ${e.message}"
|
connectionError.value = "Connection failed: ${e.message}"
|
||||||
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
|
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 -> {
|
BluetoothProfile.STATE_CONNECTED -> {
|
||||||
logd { "Connected to ${gatt.device.address}" }
|
logd { "Connected to ${gatt.device.address}" }
|
||||||
isReconnecting.value = false
|
isReconnecting.value = false
|
||||||
|
isConnecting.value = false
|
||||||
isUserDisconnect = false
|
isUserDisconnect = false
|
||||||
connectionError.value = null
|
connectionError.value = null
|
||||||
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
||||||
@@ -819,6 +825,7 @@ class BleManager private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||||
logd { "Disconnected from ${gatt.device.address}" }
|
logd { "Disconnected from ${gatt.device.address}" }
|
||||||
|
isConnecting.value = false
|
||||||
stopBatteryPolling()
|
stopBatteryPolling()
|
||||||
bluetoothGatt = null
|
bluetoothGatt = null
|
||||||
txCharacteristic = null
|
txCharacteristic = null
|
||||||
|
|||||||
+24
-1
@@ -135,6 +135,11 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
else Color(0xFFFF9800).copy(alpha = 0.15f)
|
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(
|
Row(
|
||||||
modifier = Modifier.padding(12.dp).fillMaxWidth(),
|
modifier = Modifier.padding(12.dp).fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@@ -159,6 +164,15 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
onClick = { showUnpairConfirm = true },
|
onClick = { showUnpairConfirm = true },
|
||||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFE53935))
|
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFE53935))
|
||||||
) { Text("Unpair", fontSize = 12.sp) }
|
) { 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 {
|
} else {
|
||||||
Text("BLE Not Connected", fontWeight = FontWeight.Bold, fontSize = 14.sp,
|
Text("BLE Not Connected", fontWeight = FontWeight.Bold, fontSize = 14.sp,
|
||||||
color = Color(0xFFFF9800), modifier = Modifier.weight(1f))
|
color = Color(0xFFFF9800), modifier = Modifier.weight(1f))
|
||||||
@@ -403,11 +417,20 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Notes — Examiner처럼 키보드 우하단 ✓(Done)으로 완료 가능
|
||||||
|
val notesFocus = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = notes,
|
value = notes,
|
||||||
onValueChange = { notes = it },
|
onValueChange = { notes = it },
|
||||||
label = { Text("Notes (선택)") },
|
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))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|||||||
+18
-17
@@ -374,34 +374,35 @@ private fun ChannelChart(
|
|||||||
) {
|
) {
|
||||||
val antColor = Color(0xFF4CAF50) // green = anterior wall
|
val antColor = Color(0xFF4CAF50) // green = anterior wall
|
||||||
val postColor = Color(0xFFFF9800) // orange = posterior wall
|
val postColor = Color(0xFFFF9800) // orange = posterior wall
|
||||||
|
// 사용자 피드백: 태블릿 2x3 grid에서 그래프 작음 → 셀 크기 + 폰트 + 선 굵기 모두 키움
|
||||||
Card(
|
Card(
|
||||||
shape = RoundedCornerShape(6.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(8.dp)
|
.size(12.dp)
|
||||||
.clip(RoundedCornerShape(2.dp))
|
.clip(RoundedCornerShape(3.dp))
|
||||||
.background(color)
|
.background(color)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text("CH$channelIdx", fontSize = 10.sp, fontWeight = FontWeight.Bold)
|
Text("CH$channelIdx", fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
if (walls != null) {
|
if (walls != null) {
|
||||||
Text("a${walls.first}", fontSize = 9.sp, color = antColor, fontWeight = FontWeight.SemiBold)
|
Text("a${walls.first}", fontSize = 14.sp, color = antColor, fontWeight = FontWeight.SemiBold)
|
||||||
Spacer(modifier = Modifier.width(3.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text("p${walls.second}", fontSize = 9.sp, color = postColor, fontWeight = FontWeight.SemiBold)
|
Text("p${walls.second}", fontSize = 14.sp, color = postColor, fontWeight = FontWeight.SemiBold)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
if (buffer.isNotEmpty()) {
|
if (buffer.isNotEmpty()) {
|
||||||
val peak = buffer.maxOrNull()?.toInt() ?: 0
|
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()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
if (buffer.isEmpty()) return@Canvas
|
if (buffer.isEmpty()) return@Canvas
|
||||||
val w = size.width
|
val w = size.width
|
||||||
@@ -429,22 +430,22 @@ private fun ChannelChart(
|
|||||||
val (antIdx, postIdx) = walls
|
val (antIdx, postIdx) = walls
|
||||||
if (antIdx in 0 until n) {
|
if (antIdx in 0 until n) {
|
||||||
val x = antIdx * xScale
|
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) {
|
if (postIdx in 0 until n) {
|
||||||
val x = postIdx * xScale
|
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()
|
val path = Path()
|
||||||
for (i in buffer.indices) {
|
for (i in buffer.indices) {
|
||||||
val x = i * xScale
|
val x = i * xScale
|
||||||
val y = h - (buffer[i].toInt() - yMin) * yScale
|
val y = h - (buffer[i].toInt() - yMin) * yScale
|
||||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user