diff --git a/app/src/main/java/com/example/medilightv2android/AppState.kt b/app/src/main/java/com/example/medilightv2android/AppState.kt index 1ce3f95..aa210cb 100644 --- a/app/src/main/java/com/example/medilightv2android/AppState.kt +++ b/app/src/main/java/com/example/medilightv2android/AppState.kt @@ -12,7 +12,7 @@ import com.example.medilightv2android.services.* enum class AppScreen { 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, VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS } @@ -121,6 +121,10 @@ class AppState(private val context: Context) { fun personalizationComplete() { storage.piezoSettings = piezoSettings + currentScreen = AppScreen.PLACEMENT_GUIDE + } + + fun placementComplete() { currentScreen = AppScreen.PIEZO_MONITORING } diff --git a/app/src/main/java/com/example/medilightv2android/MainActivity.kt b/app/src/main/java/com/example/medilightv2android/MainActivity.kt index 7bec70f..b7a88ac 100644 --- a/app/src/main/java/com/example/medilightv2android/MainActivity.kt +++ b/app/src/main/java/com/example/medilightv2android/MainActivity.kt @@ -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.history.MeasurementHistoryView 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.VivamyoMonitoringView import com.example.medilightv2android.ui.views.monitoring.UrineCameraScreen @@ -74,6 +75,7 @@ fun MedilightApp(modifier: Modifier = Modifier) { AppScreen.DEVICE_SCAN -> DeviceScanView(appState) AppScreen.NIRS_CALIBRATION -> CalibrationView(appState) AppScreen.PIEZO_PERSONALIZATION -> PiezoPersonalizationView(appState) + AppScreen.PLACEMENT_GUIDE -> PlacementGuideView(appState) AppScreen.PIN_SETUP -> PinView(appState, PinMode.SETUP, context) AppScreen.PIN_ENTRY -> PinView(appState, PinMode.ENTER, context) AppScreen.VIVAMYO_MONITORING -> VivamyoMonitoringView(appState) diff --git a/app/src/main/java/com/example/medilightv2android/services/MeasurementLogService.kt b/app/src/main/java/com/example/medilightv2android/services/MeasurementLogService.kt new file mode 100644 index 0000000..71960cc --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/services/MeasurementLogService.kt @@ -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, + rawADC: List> + ) { + 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, + 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 { + return logDir.listFiles { f -> f.extension == "jsonl" } + ?.sortedByDescending { it.name } + ?: emptyList() + } + + /** Read a log file as lines */ + fun readLog(file: File): List { + 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 +) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt new file mode 100644 index 0000000..97998c1 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -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, + 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): Pair { + 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" +}