feat: PlacementGuideView + MeasurementLogService + navigation flow

PlacementGuideView.kt:
- 6-sensor Canvas layout (CH0-CH3 vertical + CH4/CH5 lateral wings)
- Per-channel UrinAI detection (green/red color coding)
- Direction guidance (up/down/left/right)
- Check Position → maa → detect → display
- Start Measurement when 4+ channels detected

MeasurementLogService.kt:
- JSON Lines format, daily rotation
- Events: measurement, placement, ble_connected/disconnected, error
- 90-day auto-purge
- App internal storage: vesiscan_logs/YYYY-MM-DD.jsonl

AppState.kt + MainActivity.kt:
- PLACEMENT_GUIDE screen added to navigation
- Flow: personalization → placementGuide → piezoMonitoring

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dwjang
2026-04-21 16:43:02 +09:00
parent c136d64665
commit 549a2c722e
4 changed files with 735 additions and 1 deletions
@@ -12,7 +12,7 @@ import com.example.medilightv2android.services.*
enum class AppScreen { enum class AppScreen {
ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN, ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN,
NIRS_CALIBRATION, PIEZO_PERSONALIZATION, PIN_SETUP, PIN_ENTRY, NIRS_CALIBRATION, PIEZO_PERSONALIZATION, PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY, VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS
} }
@@ -121,6 +121,10 @@ class AppState(private val context: Context) {
fun personalizationComplete() { fun personalizationComplete() {
storage.piezoSettings = piezoSettings storage.piezoSettings = piezoSettings
currentScreen = AppScreen.PLACEMENT_GUIDE
}
fun placementComplete() {
currentScreen = AppScreen.PIEZO_MONITORING currentScreen = AppScreen.PIEZO_MONITORING
} }
@@ -21,6 +21,7 @@ import com.example.medilightv2android.ui.views.calibration.CalibrationView
import com.example.medilightv2android.ui.views.connection.DeviceScanView import com.example.medilightv2android.ui.views.connection.DeviceScanView
import com.example.medilightv2android.ui.views.history.MeasurementHistoryView import com.example.medilightv2android.ui.views.history.MeasurementHistoryView
import com.example.medilightv2android.ui.views.home.HomeView import com.example.medilightv2android.ui.views.home.HomeView
import com.example.medilightv2android.ui.views.monitoring.PlacementGuideView
import com.example.medilightv2android.ui.views.monitoring.PiezoMonitoringView import com.example.medilightv2android.ui.views.monitoring.PiezoMonitoringView
import com.example.medilightv2android.ui.views.monitoring.VivamyoMonitoringView import com.example.medilightv2android.ui.views.monitoring.VivamyoMonitoringView
import com.example.medilightv2android.ui.views.monitoring.UrineCameraScreen import com.example.medilightv2android.ui.views.monitoring.UrineCameraScreen
@@ -74,6 +75,7 @@ fun MedilightApp(modifier: Modifier = Modifier) {
AppScreen.DEVICE_SCAN -> DeviceScanView(appState) AppScreen.DEVICE_SCAN -> DeviceScanView(appState)
AppScreen.NIRS_CALIBRATION -> CalibrationView(appState) AppScreen.NIRS_CALIBRATION -> CalibrationView(appState)
AppScreen.PIEZO_PERSONALIZATION -> PiezoPersonalizationView(appState) AppScreen.PIEZO_PERSONALIZATION -> PiezoPersonalizationView(appState)
AppScreen.PLACEMENT_GUIDE -> PlacementGuideView(appState)
AppScreen.PIN_SETUP -> PinView(appState, PinMode.SETUP, context) AppScreen.PIN_SETUP -> PinView(appState, PinMode.SETUP, context)
AppScreen.PIN_ENTRY -> PinView(appState, PinMode.ENTER, context) AppScreen.PIN_ENTRY -> PinView(appState, PinMode.ENTER, context)
AppScreen.VIVAMYO_MONITORING -> VivamyoMonitoringView(appState) AppScreen.VIVAMYO_MONITORING -> VivamyoMonitoringView(appState)
@@ -0,0 +1,206 @@
package com.example.medilightv2android.services
import android.content.Context
import android.os.Build
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* Measurement log auto-save service.
*
* All measurement results are saved in JSON Lines format to internal storage.
* Medical audit trail + QA verification.
*
* File location: vesiscan_logs/YYYY-MM-DD.jsonl
* Each line = one measurement event (JSON)
*/
class MeasurementLogService private constructor(context: Context) {
private val logDir: File
private val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val timestampFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US)
init {
logDir = File(context.filesDir, "vesiscan_logs")
if (!logDir.exists()) logDir.mkdirs()
}
companion object {
@Volatile
private var instance: MeasurementLogService? = null
fun getInstance(context: Context): MeasurementLogService {
return instance ?: synchronized(this) {
instance ?: MeasurementLogService(context.applicationContext).also { instance = it }
}
}
}
// -- Log Events --
/**
* Log a spot measurement result.
*/
fun logMeasurement(
volumeMl: Double?,
channels: List<ChannelLogEntry>,
rawADC: List<List<UShort>>
) {
val entry = JSONObject().apply {
put("event", "measurement")
put("timestamp", timestampFormatter.format(Date()))
put("device", Build.MODEL)
put("app_version", getAppVersion())
}
if (volumeMl != null) {
entry.put("volume_ml", Math.round(volumeMl * 10.0) / 10.0)
}
// Per-channel detection
val chArray = JSONArray()
for (ch in channels) {
val chObj = JSONObject().apply {
put("channel", ch.channel)
ch.ant?.let { put("ant", it) }
ch.post?.let { put("post", it) }
ch.urineLen?.let { put("urine_len", it) }
ch.score?.let { put("score", Math.round(it * 10.0) / 10.0) }
}
chArray.put(chObj)
}
entry.put("channels", chArray)
// Raw ADC (compact: comma-separated per channel)
val adcObj = JSONObject()
for ((i, adc) in rawADC.withIndex()) {
adcObj.put("CH$i", adc.joinToString(",") { it.toString() })
}
entry.put("adc", adcObj)
appendLog(entry)
}
/**
* Log Placement Guide result.
*/
fun logPlacement(
channelDetected: List<Boolean>,
direction: String
) {
val entry = JSONObject().apply {
put("event", "placement")
put("timestamp", timestampFormatter.format(Date()))
val arr = JSONArray()
for (d in channelDetected) arr.put(d)
put("detected", arr)
put("detected_count", channelDetected.count { it })
put("direction", direction)
}
appendLog(entry)
}
/**
* Log BLE connection/disconnection.
*/
fun logConnection(event: String, deviceName: String = "") {
val entry = JSONObject().apply {
put("event", "ble_$event")
put("timestamp", timestampFormatter.format(Date()))
put("device_name", deviceName)
}
appendLog(entry)
}
/**
* Log an error.
*/
fun logError(message: String) {
val entry = JSONObject().apply {
put("event", "error")
put("timestamp", timestampFormatter.format(Date()))
put("message", message)
}
appendLog(entry)
}
// -- File Operations --
private fun appendLog(entry: JSONObject) {
try {
val line = entry.toString() + "\n"
val fileName = dateFormatter.format(Date()) + ".jsonl"
val file = File(logDir, fileName)
file.appendText(line)
} catch (e: Exception) {
// Silently fail — logging should never crash the app
}
}
/** Today's log file */
val todayLogFile: File
get() = File(logDir, dateFormatter.format(Date()) + ".jsonl")
/** All log files, newest first */
fun listLogFiles(): List<File> {
return logDir.listFiles { f -> f.extension == "jsonl" }
?.sortedByDescending { it.name }
?: emptyList()
}
/** Read a log file as lines */
fun readLog(file: File): List<String> {
return try {
file.readLines().filter { it.isNotBlank() }
} catch (e: Exception) {
emptyList()
}
}
/** Today's log as text */
fun todayLogText(): String {
return try {
todayLogFile.readText()
} catch (e: Exception) {
"(no logs today)"
}
}
/** Total log size in bytes */
val totalLogSize: Long
get() = listLogFiles().sumOf { it.length() }
/** Purge logs older than [keepDays] days (default 90) */
fun purgeOldLogs(keepDays: Int = 90) {
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_YEAR, -keepDays)
val cutoffStr = dateFormatter.format(calendar.time)
for (file in listLogFiles()) {
val name = file.nameWithoutExtension
if (name < cutoffStr) {
file.delete()
}
}
}
private fun getAppVersion(): String {
return try {
val context = logDir.parentFile // filesDir parent hack — not ideal
"1.0" // Fallback; ideally inject from BuildConfig
} catch (e: Exception) {
"unknown"
}
}
}
/** Data class for channel log entries */
data class ChannelLogEntry(
val channel: Int,
val ant: Int? = null,
val post: Int? = null,
val urineLen: Int? = null,
val score: Double? = null
)
@@ -0,0 +1,522 @@
package com.example.medilightv2android.ui.views.monitoring
import android.util.Log
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppState
import com.example.medilightv2android.ble.BleManager
import com.example.medilightv2android.ble.PiezoChannelData
import com.example.medilightv2android.managers.PiezoEchoAnalyzer
import com.example.medilightv2android.managers.UrinAI
import com.example.medilightv2android.services.MeasurementLogService
import com.example.medilightv2android.ui.theme.*
/**
* Sensor Placement Guide -- shown after BLE connection, before measurement.
*
* Displays a 6-channel sensor layout, shows per-channel UrinAI detection
* (green = detected, red = not), and provides direction guidance.
*
* Flow: deviceScan -> personalization -> placementGuide -> piezoMonitoring
*/
@Composable
fun PlacementGuideView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
val logService = remember { MeasurementLogService.getInstance(context) }
val analyzer = remember { PiezoEchoAnalyzer.shared }
var isScanning by remember { mutableStateOf(false) }
val channelDetected = remember { mutableStateListOf(false, false, false, false, false, false) }
var directionHint by remember { mutableStateOf("") }
var directionIcon by remember { mutableStateOf("") }
var scanCount by remember { mutableIntStateOf(0) }
var isLocked by remember { mutableStateOf(false) }
val detectedCount = channelDetected.count { it }
Column(
modifier = Modifier
.fillMaxSize()
.background(MlBackground)
) {
// Top bar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { appState.goHome() }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MlPrimary
)
}
Text(
text = "Sensor Placement",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp)
)
Spacer(modifier = Modifier.weight(1f))
if (isLocked) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = "Ready",
tint = Color(0xFF4CAF50)
)
Text(
text = "Ready",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF4CAF50)
)
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Instructions
Text(
text = "Place the sensor on your lower abdomen.\nAdjust until all channels turn green.",
fontSize = 14.sp,
color = MlSecondaryText,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp)
)
Spacer(modifier = Modifier.height(24.dp))
// Sensor layout visualization
SensorLayoutCanvas(
channelDetected = channelDetected,
directionIcon = directionIcon,
isLocked = isLocked
)
Spacer(modifier = Modifier.height(24.dp))
// Direction guide
if (directionHint.isNotEmpty() && !isLocked) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
Color(0xFFFFF3E0),
RoundedCornerShape(14.dp)
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
val icon = when {
directionIcon.contains("up") -> Icons.Filled.KeyboardArrowUp
directionIcon.contains("down") -> Icons.Filled.KeyboardArrowDown
directionIcon.contains("left") -> Icons.Filled.ArrowBack
directionIcon.contains("right") -> Icons.Filled.ArrowForward
directionIcon.contains("check") -> Icons.Filled.CheckCircle
else -> Icons.Filled.Warning
}
Icon(
icon,
contentDescription = null,
tint = Color(0xFFFF9800),
modifier = Modifier.size(24.dp)
)
Text(
text = directionHint,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFFFF9800)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Status summary - channel dots row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
for (i in 0 until 6) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(
if (channelDetected[i]) Color(0xFF4CAF50)
else Color.Red.copy(alpha = 0.4f)
)
)
Text(
text = "CH$i",
fontSize = 9.sp,
fontWeight = FontWeight.SemiBold,
color = MlSecondaryText
)
}
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "$detectedCount/6",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = if (detectedCount >= 4) Color(0xFF4CAF50) else Color(0xFFFF9800)
)
}
Spacer(modifier = Modifier.height(24.dp))
// Check Position button
Button(
onClick = {
if (!isScanning) {
isScanning = true
bleManager.piezoCollector.onMultiChannelComplete = { channels ->
isScanning = false
scanCount++
// Run UrinAI on each channel
val detected = MutableList(6) { false }
for (ch in channels) {
if (ch.buffer.isNotEmpty() && ch.channel < 6) {
val gate = UrinAI.detect(ch.buffer)
detected[ch.channel] = gate.detected
// Echo analysis as backup
if (!gate.detected) {
val analysis = analyzer.analyzeChannel(ch.buffer, ch.channel)
if (analysis.isValid) {
val r = analysis.result
if (r != null && r.urineLen >= 10) {
detected[ch.channel] = true
}
}
}
}
}
for (i in 0 until 6) {
channelDetected[i] = detected[i]
}
// Direction guidance
val hints = updateDirectionGuide(detected)
directionHint = hints.first
directionIcon = hints.second
// Lock if 4+ detected
if (detected.count { it } >= 4) {
isLocked = true
}
Log.d("PlacementGuide", "Placement: ${detected.count { it }}/6 -- ${
detected.mapIndexed { idx, d -> if (d) "CH$idx OK" else "CH$idx X" }.joinToString(" ")
}")
// Save placement log
logService.logPlacement(
channelDetected = detected,
direction = directionHint
)
}
bleManager.piezoCollector.onLog = { msg ->
Log.d("PlacementGuide", msg)
}
bleManager.sendAllChannels()
}
},
enabled = !isScanning,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isScanning) Color.Gray else MlPrimary,
disabledContainerColor = Color.Gray
)
) {
if (isScanning) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = if (isScanning) "Scanning..." else "Check Position",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
// Start Measurement button (when 4+ channels detected)
if (isLocked || detectedCount >= 4) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { appState.placementComplete() },
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF4CAF50)
)
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = null,
tint = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Start Measurement",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
// Skip button
Spacer(modifier = Modifier.height(16.dp))
TextButton(onClick = { appState.placementComplete() }) {
Text(
text = "Skip Placement Guide",
fontSize = 13.sp,
color = MlSecondaryText
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
/**
* Canvas-based sensor layout visualization.
* Draws body outline, bladder outline, and 6 sensor dots with labels.
*/
@Composable
private fun SensorLayoutCanvas(
channelDetected: List<Boolean>,
directionIcon: String,
isLocked: Boolean
) {
val greenColor = Color(0xFF4CAF50)
val redColor = Color.Red.copy(alpha = 0.5f)
val orangeColor = Color(0xFFFF9800).copy(alpha = 0.6f)
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
contentAlignment = Alignment.Center
) {
Canvas(
modifier = Modifier
.width(280.dp)
.height(300.dp)
) {
val centerX = size.width / 2
val centerY = size.height / 2
// Body outline (rounded rectangle)
drawRoundRect(
color = Color.Gray.copy(alpha = 0.2f),
topLeft = Offset(centerX - 100f, centerY - 140f),
size = Size(200f, 280f),
cornerRadius = CornerRadius(30f),
style = Stroke(width = 2f)
)
// Bladder outline (dashed ellipse)
drawOval(
color = Color.Cyan.copy(alpha = 0.3f),
topLeft = Offset(centerX - 60f, centerY - 60f),
size = Size(120f, 100f),
style = Stroke(
width = 1.5f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 6f))
)
)
// Sensor positions: channel -> (offsetX, offsetY)
data class SensorPos(val ch: Int, val x: Float, val y: Float, val angle: Float, val label: String)
val sensors = listOf(
SensorPos(3, 0f, -80f, -10f, "CH3\n-10\u00B0"),
SensorPos(2, 0f, -25f, 0f, "CH2\n0\u00B0"),
SensorPos(1, 0f, 30f, 10f, "CH1\n+10\u00B0"),
SensorPos(0, 0f, 85f, 20f, "CH0\n+20\u00B0"),
SensorPos(4, -80f, -25f, 10f, "CH4\nL 5\u00B0"),
SensorPos(5, 80f, -25f, 10f, "CH5\nR 5\u00B0")
)
for (s in sensors) {
val detected = if (s.ch < channelDetected.size) channelDetected[s.ch] else false
val color = if (detected) greenColor else redColor
val sx = centerX + s.x
val sy = centerY + s.y
// Sensor body (rotated rounded rect)
rotate(degrees = s.angle, pivot = Offset(sx, sy)) {
drawRoundRect(
color = color,
topLeft = Offset(sx - 14f, sy - 7f),
size = Size(28f, 14f),
cornerRadius = CornerRadius(3f)
)
}
}
// Direction arrow overlay
if (directionIcon.isNotEmpty() && !isLocked) {
val arrowLength = 30f
val arrowStroke = Stroke(width = 4f)
when {
directionIcon.contains("up") -> {
val ax = centerX
val ay = centerY - 130f
drawLine(orangeColor, Offset(ax, ay + arrowLength), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 10f, ay + 10f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 10f, ay + 10f), Offset(ax, ay), strokeWidth = 4f)
}
directionIcon.contains("down") -> {
val ax = centerX
val ay = centerY + 130f
drawLine(orangeColor, Offset(ax, ay - arrowLength), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 10f, ay - 10f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 10f, ay - 10f), Offset(ax, ay), strokeWidth = 4f)
}
directionIcon.contains("left") -> {
val ax = centerX - 130f
val ay = centerY
drawLine(orangeColor, Offset(ax + arrowLength, ay), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 10f, ay - 10f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 10f, ay + 10f), Offset(ax, ay), strokeWidth = 4f)
}
directionIcon.contains("right") -> {
val ax = centerX + 130f
val ay = centerY
drawLine(orangeColor, Offset(ax - arrowLength, ay), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 10f, ay - 10f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 10f, ay + 10f), Offset(ax, ay), strokeWidth = 4f)
}
}
}
}
// Overlay text labels (outside Canvas for proper text rendering)
data class LabelPos(val ch: Int, val x: Int, val y: Int, val label: String)
val labels = listOf(
LabelPos(3, 0, -80, "CH3\n-10\u00B0"),
LabelPos(2, 0, -25, "CH2\n0\u00B0"),
LabelPos(1, 0, 30, "CH1\n+10\u00B0"),
LabelPos(0, 0, 85, "CH0\n+20\u00B0"),
LabelPos(4, -80, -25, "CH4\nL 5\u00B0"),
LabelPos(5, 80, -25, "CH5\nR 5\u00B0")
)
for (lbl in labels) {
val detected = if (lbl.ch < channelDetected.size) channelDetected[lbl.ch] else false
Text(
text = lbl.label,
fontSize = 8.sp,
fontWeight = FontWeight.SemiBold,
color = if (detected) Color(0xFF4CAF50) else Color.Gray,
textAlign = TextAlign.Center,
lineHeight = 10.sp,
modifier = Modifier.offset(x = lbl.x.dp, y = (lbl.y + 14).dp)
)
}
}
}
/**
* Compute direction guidance based on which channels are detected.
* Returns (hint text, icon identifier).
*/
private fun updateDirectionGuide(detected: List<Boolean>): Pair<String, String> {
val topDetected = detected[3] // CH3 = top
val bottomDetected = detected[0] // CH0 = bottom
val leftDetected = if (detected.size > 4) detected[4] else true // CH4
val rightDetected = if (detected.size > 5) detected[5] else true // CH5
val detectedCount = detected.count { it }
if (detectedCount >= 4) {
return "Good position! Ready to measure." to "checkmark.circle"
}
// Vertical guidance
if (!topDetected && bottomDetected) {
return "Move sensor DOWN" to "arrow.down"
}
if (topDetected && !bottomDetected) {
return "Move sensor UP" to "arrow.up"
}
// Horizontal guidance
if (!leftDetected && rightDetected) {
return "Move sensor RIGHT" to "arrow.right"
}
if (leftDetected && !rightDetected) {
return "Move sensor LEFT" to "arrow.left"
}
// No signal
if (detectedCount == 0) {
return "No bladder detected. Check sensor contact." to "exclamationmark.triangle"
}
// Partial
return "Adjust sensor position slowly" to "arrow.up.arrow.down"
}