fix: placement direction guide 수정 + sensor layout 확대 + BLE prefix 필터(2025/VBT)

- PlacementGuideView: 방향 가이드 로직 수정 (CH0=배꼽, CH3=치골 기준)
  CH0✅CH3❌→UP, CH3✅CH0❌→DOWN, 좌우도 동일하게 보정
- SensorLayoutCanvas: 스케일 16px/mm, 방광 280×220, body 400×400
- DeviceScanView: BLE prefix 필터 "2025" | "VBT"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 16:19:53 +09:00
parent d8db7671ac
commit 5cc2321ff4
3 changed files with 113 additions and 76 deletions
+4 -1
View File
@@ -1,7 +1,10 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(git push -u origin master)" "Bash(git push -u origin master)",
"Bash(git pull:*)",
"Bash(./gradlew assembleDebug)",
"Bash(xargs grep:*)"
] ]
} }
} }
@@ -45,7 +45,7 @@ fun DeviceScanView(appState: AppState) {
var permissionsGranted by remember { mutableStateOf(false) } var permissionsGranted by remember { mutableStateOf(false) }
val filteredDevices = bleManager.discoveredDevices val filteredDevices = bleManager.discoveredDevices
.filter { it.name.isNotBlank() } .filter { it.name.startsWith("2025") || it.name.startsWith("VBT") }
.sortedByDescending { it.rssi } .sortedByDescending { it.rssi }
val isConnected by bleManager.isConnected val isConnected by bleManager.isConnected
@@ -396,8 +396,9 @@ fun PlacementGuideView(appState: AppState) {
} }
/** /**
* Canvas-based sensor layout visualization. * Canvas-based sensor layout on body — sensors overlaid on abdomen/bladder.
* Draws body outline, bladder outline, and 6 sensor dots with labels. * Actual hardware spec proportions (cross array 4+2).
* Scale: 1mm ≈ 8px, centered on body.
*/ */
@Composable @Composable
private fun SensorLayoutCanvas( private fun SensorLayoutCanvas(
@@ -409,119 +410,149 @@ private fun SensorLayoutCanvas(
val redColor = Color.Red.copy(alpha = 0.5f) val redColor = Color.Red.copy(alpha = 0.5f)
val orangeColor = Color(0xFFFF9800).copy(alpha = 0.6f) val orangeColor = Color(0xFFFF9800).copy(alpha = 0.6f)
val chColors = listOf(
Color(0xFFFF6B6B), // CH0
Color(0xFFFFB347), // CH1
Color(0xFF87CF3E), // CH2
Color(0xFF4FC3F7), // CH3
Color(0xFF64B5F6), // CH4
Color(0xFFCE93D8) // CH5
)
val s = 16f // scale: 1mm = 16px
data class SensorSpec(val ch: Int, val xMm: Float, val yMm: Float, val tilt: Float, val wMm: Float, val hMm: Float)
val sensors = listOf(
SensorSpec(3, 0f, 0f, 0f, 12f, 6f),
SensorSpec(2, 0f, 7f, 0f, 12f, 6f),
SensorSpec(1, 0f, 14f, 0f, 12f, 6f),
SensorSpec(0, 0f, 21f, 0f, 12f, 6f),
SensorSpec(4, -10f, 9.85f, -5f, 6f, 12f),
SensorSpec(5, 10f, 9.85f, 5f, 6f, 12f)
)
val arrayMidMm = 10.5f
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(400.dp), .height(420.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.width(340.dp) .fillMaxWidth()
.height(400.dp) .height(420.dp)
) { ) {
val centerX = size.width / 2 val cx = size.width / 2
val centerY = size.height / 2 val cy = size.height / 2
// Body outline (rounded rectangle) — enlarged // Body outline (abdomen area)
drawRoundRect( drawRoundRect(
color = Color.Gray.copy(alpha = 0.2f), color = Color.Gray.copy(alpha = 0.15f),
topLeft = Offset(centerX - 140f, centerY - 185f), topLeft = Offset(cx - 200f, cy - 200f),
size = Size(280f, 370f), size = Size(400f, 400f),
cornerRadius = CornerRadius(40f), cornerRadius = CornerRadius(50f),
style = Stroke(width = 2f) style = Stroke(width = 2f)
) )
// Bladder outline (dashed ellipse) — enlarged // Bladder outline (behind sensors)
drawOval( drawOval(
color = Color.Cyan.copy(alpha = 0.3f), color = Color.Cyan.copy(alpha = 0.2f),
topLeft = Offset(centerX - 85f, centerY - 80f), topLeft = Offset(cx - 140f, cy - 110f),
size = Size(170f, 140f), size = Size(280f, 220f),
style = Stroke( style = Stroke(
width = 1.5f, width = 2f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 6f)) pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 6f))
) )
) )
drawOval(
// Sensor positions — spread out more color = Color.Cyan.copy(alpha = 0.05f),
data class SensorPos(val ch: Int, val x: Float, val y: Float, val angle: Float) topLeft = Offset(cx - 140f, cy - 110f),
val sensors = listOf( size = Size(280f, 220f)
SensorPos(3, 0f, -120f, -10f),
SensorPos(2, 0f, -40f, 0f),
SensorPos(1, 0f, 40f, 10f),
SensorPos(0, 0f, 120f, 20f),
SensorPos(4, -110f, -40f, 10f),
SensorPos(5, 110f, -40f, 10f)
) )
for (s in sensors) { // Draw sensors centered on body
val detected = if (s.ch < channelDetected.size) channelDetected[s.ch] else false for (spec in sensors) {
val color = if (detected) greenColor else redColor val detected = if (spec.ch < channelDetected.size) channelDetected[spec.ch] else false
val sx = centerX + s.x val color = if (detected) chColors[spec.ch] else chColors[spec.ch].copy(alpha = 0.25f)
val sy = centerY + s.y val border = if (detected) chColors[spec.ch] else Color.Gray.copy(alpha = 0.4f)
val px = cx + spec.xMm * s
val py = cy + (spec.yMm - arrayMidMm) * s
val pw = spec.wMm * s
val ph = spec.hMm * s
rotate(degrees = s.angle, pivot = Offset(sx, sy)) { rotate(degrees = spec.tilt, pivot = Offset(px, py)) {
drawRoundRect( drawRoundRect(
color = color, color = color.copy(alpha = 0.4f),
topLeft = Offset(sx - 20f, sy - 10f), topLeft = Offset(px - pw / 2, py - ph / 2),
size = Size(40f, 20f), size = Size(pw, ph),
cornerRadius = CornerRadius(4f) cornerRadius = CornerRadius(6f)
)
drawRoundRect(
color = border,
topLeft = Offset(px - pw / 2, py - ph / 2),
size = Size(pw, ph),
cornerRadius = CornerRadius(6f),
style = Stroke(width = 3f)
) )
} }
} }
// Direction arrow overlay // Direction arrows
if (directionIcon.isNotEmpty() && !isLocked) { if (directionIcon.isNotEmpty() && !isLocked) {
val arrowLength = 40f val al = 45f
when { when {
directionIcon.contains("up") -> { directionIcon.contains("up") -> {
val ax = centerX; val ay = centerY - 175f val ax = cx; val ay = cy - 190f
drawLine(orangeColor, Offset(ax, ay + arrowLength), Offset(ax, ay), strokeWidth = 4f) drawLine(orangeColor, Offset(ax, ay + al), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 12f, ay + 12f), Offset(ax, ay), strokeWidth = 4f) drawLine(orangeColor, Offset(ax - 14f, ay + 14f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 12f, ay + 12f), Offset(ax, ay), strokeWidth = 4f) drawLine(orangeColor, Offset(ax + 14f, ay + 14f), Offset(ax, ay), strokeWidth = 4f)
} }
directionIcon.contains("down") -> { directionIcon.contains("down") -> {
val ax = centerX; val ay = centerY + 175f val ax = cx; val ay = cy + 190f
drawLine(orangeColor, Offset(ax, ay - arrowLength), Offset(ax, ay), strokeWidth = 4f) drawLine(orangeColor, Offset(ax, ay - al), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 12f, ay - 12f), Offset(ax, ay), strokeWidth = 4f) drawLine(orangeColor, Offset(ax - 14f, ay - 14f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 12f, ay - 12f), Offset(ax, ay), strokeWidth = 4f) drawLine(orangeColor, Offset(ax + 14f, ay - 14f), Offset(ax, ay), strokeWidth = 4f)
} }
directionIcon.contains("left") -> { directionIcon.contains("left") -> {
val ax = centerX - 170f; val ay = centerY val ax = cx - 190f; val ay = cy
drawLine(orangeColor, Offset(ax + arrowLength, ay), Offset(ax, ay), strokeWidth = 4f) drawLine(orangeColor, Offset(ax + al, ay), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 12f, ay - 12f), Offset(ax, ay), strokeWidth = 4f) drawLine(orangeColor, Offset(ax + 14f, ay - 14f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 12f, ay + 12f), Offset(ax, ay), strokeWidth = 4f) drawLine(orangeColor, Offset(ax + 14f, ay + 14f), Offset(ax, ay), strokeWidth = 4f)
} }
directionIcon.contains("right") -> { directionIcon.contains("right") -> {
val ax = centerX + 170f; val ay = centerY val ax = cx + 190f; val ay = cy
drawLine(orangeColor, Offset(ax - arrowLength, ay), Offset(ax, ay), strokeWidth = 4f) drawLine(orangeColor, Offset(ax - al, ay), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 12f, ay - 12f), Offset(ax, ay), strokeWidth = 4f) drawLine(orangeColor, Offset(ax - 14f, ay - 14f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 12f, ay + 12f), Offset(ax, ay), strokeWidth = 4f) drawLine(orangeColor, Offset(ax - 14f, ay + 14f), Offset(ax, ay), strokeWidth = 4f)
} }
} }
} }
} }
// Overlay text labels — repositioned for larger layout // Channel labels next to sensors
data class LabelPos(val ch: Int, val x: Int, val y: Int, val label: String) data class LabelInfo(val ch: Int, val xMm: Float, val yMm: Float, val label: String)
val labels = listOf( val labelSpecs = listOf(
LabelPos(3, 0, -120, "CH3\n-10\u00B0"), LabelInfo(3, 0f, 0f, "CH3 · 30\u00B0"),
LabelPos(2, 0, -40, "CH2\n0\u00B0"), LabelInfo(2, 0f, 7f, "CH2 · 20\u00B0"),
LabelPos(1, 0, 40, "CH1\n+10\u00B0"), LabelInfo(1, 0f, 14f, "CH1 · 10\u00B0"),
LabelPos(0, 0, 120, "CH0\n+20\u00B0"), LabelInfo(0, 0f, 21f, "CH0 · 0\u00B0"),
LabelPos(4, -110, -40, "CH4\nL 5\u00B0"), LabelInfo(4, -10f, 9.85f, "CH4"),
LabelPos(5, 110, -40, "CH5\nR 5\u00B0") LabelInfo(5, 10f, 9.85f, "CH5")
) )
for (lbl in labels) { for (lbl in labelSpecs) {
val detected = if (lbl.ch < channelDetected.size) channelDetected[lbl.ch] else false val detected = if (lbl.ch < channelDetected.size) channelDetected[lbl.ch] else false
val yOff = (lbl.yMm - arrayMidMm) * s
val xOff = if (lbl.ch == 4) (lbl.xMm * s - 60f)
else if (lbl.ch == 5) (lbl.xMm * s + 60f)
else (12f * s / 2 + 12f)
Text( Text(
text = lbl.label, text = lbl.label,
fontSize = 9.sp, fontSize = 11.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = if (detected) Color(0xFF4CAF50) else Color.Gray, color = if (detected) chColors[lbl.ch] else Color.Gray.copy(alpha = 0.6f),
textAlign = TextAlign.Center, modifier = Modifier.offset(x = xOff.dp, y = yOff.dp)
lineHeight = 11.sp,
modifier = Modifier.offset(x = lbl.x.dp, y = (lbl.y + 18).dp)
) )
} }
} }
@@ -544,19 +575,22 @@ private fun updateDirectionGuide(detected: List<Boolean>): Pair<String, String>
} }
// Vertical guidance // Vertical guidance
// CH0=위(배꼽), CH3=아래(치골)
// CH0✅ CH3❌ → 방광이 센서보다 위 → 센서를 위로
// CH0❌ CH3✅ → 방광이 센서보다 아래 → 센서를 아래로
if (!topDetected && bottomDetected) { if (!topDetected && bottomDetected) {
return "Move sensor DOWN" to "arrow.down" return "Move sensor UP" to "arrow.up"
} }
if (topDetected && !bottomDetected) { if (topDetected && !bottomDetected) {
return "Move sensor UP" to "arrow.up" return "Move sensor DOWN" to "arrow.down"
} }
// Horizontal guidance // Horizontal guidance
if (!leftDetected && rightDetected) { if (!leftDetected && rightDetected) {
return "Move sensor RIGHT" to "arrow.right" return "Move sensor LEFT" to "arrow.left"
} }
if (leftDetected && !rightDetected) { if (leftDetected && !rightDetected) {
return "Move sensor LEFT" to "arrow.left" return "Move sensor RIGHT" to "arrow.right"
} }
// No signal // No signal