diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c422d56..5eb8438 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -57,6 +57,16 @@ dependencies { implementation(libs.androidx.security.crypto) implementation(libs.rive.android) implementation(libs.androidx.startup.runtime) + + // 소변컵 측정 모듈 + implementation("com.microsoft.onnxruntime:onnxruntime-android:1.17.0") // YOLO + implementation("com.google.mlkit:text-recognition:16.0.1") // OCR + implementation("androidx.camera:camera-core:1.3.4") // CameraX + implementation("androidx.camera:camera-camera2:1.3.4") + implementation("androidx.camera:camera-lifecycle:1.3.4") + implementation("androidx.camera:camera-view:1.3.4") + implementation("com.squareup.okhttp3:okhttp:4.12.0") // HF API + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5eedac3..d33e876 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,16 @@ + + + + + + + + + + diff --git a/app/src/main/assets/urinecup_best.onnx b/app/src/main/assets/urinecup_best.onnx new file mode 100644 index 0000000..9baff5c Binary files /dev/null and b/app/src/main/assets/urinecup_best.onnx differ diff --git a/app/src/main/java/com/example/medilightv2android/measure/CCPosition.kt b/app/src/main/java/com/example/medilightv2android/measure/CCPosition.kt new file mode 100644 index 0000000..5978d5d --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/measure/CCPosition.kt @@ -0,0 +1,8 @@ +package com.example.medilightv2android.measure + +data class CCPosition( + val cc: Int, + val x: Int, + val y: Int, + val predicted: Boolean = false +) diff --git a/app/src/main/java/com/example/medilightv2android/measure/SimpleMeasureService.kt b/app/src/main/java/com/example/medilightv2android/measure/SimpleMeasureService.kt new file mode 100644 index 0000000..6871857 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/measure/SimpleMeasureService.kt @@ -0,0 +1,287 @@ +package com.example.medilightv2android.measure + +import android.graphics.Bitmap +import android.graphics.Color +import android.util.Log +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import com.example.medilightv2android.measure.CCPosition +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.math.roundToInt + +/** + * V2 측정 서비스 — 순수 Kotlin, C++/OpenCV 불필요. + * iOS 3-zone Center 2-peak 방식. + * + * YOLO crop → OCR → 3-zone gradient (Center 2-peak) → yToCC + */ +object SimpleMeasureService { + private const val TAG = "SimpleMeasure" + + data class MeasureResult( + val volume: Int?, + val isSmallVolume: Boolean = false, + val timeMs: Long = 0, + val log: String = "" + ) + + suspend fun measure(bitmap: Bitmap): MeasureResult = withContext(Dispatchers.Default) { + val log = StringBuilder() + val t0 = System.currentTimeMillis() + + // 밝기 체크 + val brightness = checkBrightness(bitmap) + log.appendLine("밝기: ${"%.0f".format(brightness)}/255") + if (brightness < 80) { + log.appendLine("⚠ 이미지가 너무 어둡습니다") + return@withContext MeasureResult(null, log = log.toString(), timeMs = System.currentTimeMillis() - t0) + } + + // OCR + val rawOcr = runOCR(bitmap) + val t1 = System.currentTimeMillis() + val ocrPositions = filterOcrPositions(rawOcr) + log.appendLine("OCR: ${t1 - t0}ms → ${ocrPositions.size}개 눈금") + + if (ocrPositions.size < 2) { + return@withContext MeasureResult(null, log = log.toString() + "눈금 부족", timeMs = System.currentTimeMillis() - t0) + } + + // 스캔 영역: 0~500cc 전체 범위로 외삽 + val sorted = ocrPositions.sortedBy { it.y } + val avgGap = (sorted.last().y - sorted.first().y).toFloat() / (sorted.size - 1) + val pxPer50cc = avgGap + val topOcrCC = sorted.first().cc.toFloat() // e.g., 450 + val botOcrCC = sorted.last().cc.toFloat() // e.g., 100 + val gapsAbove = (500f - topOcrCC) / 50f + val gapsBelow = botOcrCC / 50f + val scanTop = (sorted.first().y - pxPer50cc * gapsAbove - 10).toInt().coerceAtLeast(0) + val scanBot = (sorted.last().y + pxPer50cc * gapsBelow + 10).toInt().coerceAtMost(bitmap.height - 1) + val width = bitmap.width + + // OCR 좌표 기반 zone 설정 + val ocrMaxX = ocrPositions.maxOf { it.x } + val ocrOnLeft = ocrPositions.map { it.x }.average() < width / 2.0 + + val zoneALeft: Int; val zoneARight: Int + val zoneBLeft: Int; val zoneBRight: Int + val zoneCLeft: Int; val zoneCRight: Int + + if (ocrOnLeft) { + zoneALeft = (width * 0.15).toInt(); zoneARight = (width * 0.33).toInt() + zoneBLeft = (width * 0.35).toInt(); zoneBRight = (width * 0.55).toInt() + zoneCLeft = (width * 0.60).toInt(); zoneCRight = (width * 0.85).toInt() + } else { + zoneALeft = (width * 0.15).toInt(); zoneARight = (width * 0.40).toInt() + zoneBLeft = (width * 0.45).toInt(); zoneBRight = (width * 0.65).toInt() + zoneCLeft = (width * 0.67).toInt(); zoneCRight = (width * 0.85).toInt() + } + + // 3-zone: Center(B) primary + Left(A)/Right(C) cross-zone 확인 + val peaksB = computeZonePeaks(bitmap, scanTop, scanBot, zoneBLeft, zoneBRight) + val peaksA = computeZonePeaks(bitmap, scanTop, scanBot, zoneALeft, zoneARight) + val peaksC = computeZonePeaks(bitmap, scanTop, scanBot, zoneCLeft, zoneCRight) + val t2 = System.currentTimeMillis() + + val surfaceY: Int + if (peaksB.isEmpty()) { + val allSide = peaksA + peaksC + if (allSide.isEmpty()) { + return@withContext MeasureResult(null, log = log.toString() + "감지 실패", timeMs = System.currentTimeMillis() - t0) + } + surfaceY = allSide.maxByOrNull { it.second }!!.first + } else { + val maxVal = peaksB.maxOf { it.second } + val rawSigPeaks = peaksB.filter { it.second >= maxVal * 0.3f }.sortedByDescending { it.second } + + // 근접 peak 병합: 0.3*avgGap 이내면 건너뜀 + val minPeakDist = (avgGap * 0.3f).toInt() + val sigPeaks = mutableListOf>() + for (sp in rawSigPeaks) { + val tooClose = sigPeaks.any { kotlin.math.abs(it.first - sp.first) < minPeakDist } + if (!tooClose) sigPeaks.add(sp) + } + + // Cross-zone 확인: Left(A)/Right(C)에서도 비슷한 y에 peak가 있는지 확인 + // Center peak가 OCR 텍스트 피처일 수 있으므로, 확인되는 peak를 우선 승격 + val confirmRadius = (avgGap * 0.5f).toInt() + fun countConfirming(peakY: Int): Int { + var count = 0 + if (peaksA.any { kotlin.math.abs(it.first - peakY) <= confirmRadius && it.second > 0.01f }) count++ + if (peaksC.any { kotlin.math.abs(it.first - peakY) <= confirmRadius && it.second > 0.01f }) count++ + return count + } + + // 상위 5개 중 첫 번째로 확인되는 peak를 승격 + val topCandidates = sigPeaks.take(5) + val confirmedPeak = topCandidates.firstOrNull { countConfirming(it.first) >= 1 } + val orderedPeaks = if (confirmedPeak != null && confirmedPeak != topCandidates.firstOrNull()) { + log.appendLine("승격: y=${confirmedPeak.first} (확인됨) → 최강 y=${sigPeaks[0].first} 대체") + val reordered = mutableListOf(confirmedPeak) + reordered.addAll(sigPeaks.filter { it != confirmedPeak }) + reordered + } else { + sigPeaks + } + + if (orderedPeaks.size >= 2) { + val ellipseTop = minOf(orderedPeaks[0].first, orderedPeaks[1].first) + val ellipseBot = maxOf(orderedPeaks[0].first, orderedPeaks[1].first) + val peakDistance = ellipseBot - ellipseTop + val maxEllipseSpan = (avgGap * 1.0f).toInt() + + if (peakDistance > maxEllipseSpan) { + surfaceY = orderedPeaks[0].first + log.appendLine("타원 거부: 거리 ${peakDistance}px > 한계 ${maxEllipseSpan}px → peak y=$surfaceY") + } else { + surfaceY = ellipseBot + log.appendLine("타원: top=$ellipseTop, bot=$ellipseBot, 거리=${peakDistance}px") + } + } else { + surfaceY = orderedPeaks[0].first + } + } + + // yToCC 보간 + val volume = interpolate(surfaceY, sorted) + val totalMs = System.currentTimeMillis() - t0 + log.appendLine("수면: y=$surfaceY → ${volume}ml (${totalMs}ms)") + Log.d(TAG, "V2: ${volume}ml, ${totalMs}ms") + + MeasureResult(volume = volume, timeMs = totalMs, log = log.toString()) + } + + private fun computeZonePeaks(bitmap: Bitmap, scanTop: Int, scanBot: Int, leftX: Int, rightX: Int): List> { + val height = bitmap.height + val width = bitmap.width + val satProfile = FloatArray(height) + + for (y in scanTop..scanBot) { + var satSum = 0f; var count = 0 + for (x in leftX until rightX) { + if (x < 0 || x >= width) continue + val pixel = bitmap.getPixel(x, y) + val r = Color.red(pixel) / 255f; val g = Color.green(pixel) / 255f; val b = Color.blue(pixel) / 255f + val max = maxOf(r, g, b); val min = minOf(r, g, b) + satSum += if (max > 0) (max - min) / max else 0f + count++ + } + satProfile[y] = if (count > 0) satSum / count else 0f + } + + val smoothed = FloatArray(height) + for (y in scanTop..scanBot) { + var sum = 0f; var cnt = 0 + for (k in -10..10) { val idx = y + k; if (idx in scanTop..scanBot) { sum += satProfile[idx]; cnt++ } } + smoothed[y] = if (cnt > 0) sum / cnt else 0f + } + + val step = 15 + val grad = FloatArray(height) + for (y in (scanTop + step)..(scanBot - step)) { grad[y] = smoothed[y + step] - smoothed[y] } + + val gradSmooth = FloatArray(height) + for (y in scanTop..scanBot) { + var sum = 0f; var cnt = 0 + for (k in -5..5) { val idx = y + k; if (idx in scanTop..scanBot) { sum += grad[idx]; cnt++ } } + gradSmooth[y] = if (cnt > 0) sum / cnt else 0f + } + + val peaks = mutableListOf>() + for (y in (scanTop + 1)..(scanBot - 1)) { + if (gradSmooth[y] > 0 && gradSmooth[y] >= gradSmooth[y - 1] && gradSmooth[y] >= gradSmooth[y + 1]) { + peaks.add(Pair(y, gradSmooth[y])) + } + } + return peaks + } + + private fun interpolate(surfaceY: Int, sorted: List): Int? { + if (sorted.size < 2) return null + + // 외삽: OCR 최상단 위 (450cc 이상 → 500cc 방향) + if (surfaceY <= sorted.first().y) { + val pxPerCC = (sorted[1].y - sorted[0].y).toFloat() / (sorted[1].cc - sorted[0].cc) + val extrapolated = sorted.first().cc + (surfaceY - sorted.first().y) / pxPerCC + return extrapolated.coerceIn(0f, 500f).roundToInt() + } + + // 외삽: OCR 최하단 아래 (100cc 이하 → 0cc 방향) + if (surfaceY >= sorted.last().y) { + val n = sorted.size + val pxPerCC = (sorted[n - 1].y - sorted[n - 2].y).toFloat() / (sorted[n - 1].cc - sorted[n - 2].cc) + val extrapolated = sorted.last().cc + (surfaceY - sorted.last().y) / pxPerCC + return extrapolated.coerceIn(0f, 500f).roundToInt() + } + + // 보간: 인접한 두 마크 사이 + for (i in 0 until sorted.size - 1) { + val upper = sorted[i]; val lower = sorted[i + 1] + if (surfaceY in upper.y..lower.y) { + val t = (surfaceY - upper.y).toFloat() / (lower.y - upper.y).toFloat() + return (upper.cc + t * (lower.cc - upper.cc)).roundToInt() + } + } + return null + } + + private fun checkBrightness(bitmap: Bitmap): Float { + val w = bitmap.width; val h = bitmap.height + var sum = 0L; var count = 0 + var y = h / 6 + while (y < h / 3) { + var x = w / 4 + while (x < w * 3 / 4) { + val p = bitmap.getPixel(x, y) + sum += (Color.red(p) * 299 + Color.green(p) * 587 + Color.blue(p) * 114) / 1000 + count++; x += 4 + }; y += 4 + } + return if (count > 0) sum.toFloat() / count else 0f + } + + private suspend fun runOCR(bitmap: Bitmap): List = + suspendCancellableCoroutine { cont -> + val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + recognizer.process(InputImage.fromBitmap(bitmap, 0)) + .addOnSuccessListener { visionText -> + val positions = mutableListOf() + val regex = Regex("""(\d{2,3})\s*(?:cc|ml|CC|ML|Cc|mL)?""") + for (block in visionText.textBlocks) { + for (line in block.lines) { + val match = regex.find(line.text) ?: continue + val ccVal = match.groupValues[1].toIntOrNull() ?: continue + if (ccVal !in 50..500 || ccVal % 50 != 0) continue + val bbox = line.boundingBox ?: continue + positions.add(CCPosition(cc = ccVal, x = bbox.centerX(), y = bbox.centerY())) + } + } + cont.resume(positions.distinctBy { it.cc }.sortedBy { it.y }) + } + .addOnFailureListener { cont.resume(emptyList()) } + } + + private fun filterOcrPositions(raw: List): List { + if (raw.size < 2) return raw + val sortedByY = raw.sortedBy { it.y } + val valid = mutableListOf() + for (pos in sortedByY) { + if (valid.isEmpty() || pos.cc < valid.last().cc) valid.add(pos) + } + if (valid.size >= 3) { + val gaps = (0 until valid.size - 1).map { valid[it + 1].y - valid[it].y } + val medianGap = gaps.sorted()[gaps.size / 2] + val filtered = mutableListOf(valid.first()) + for (i in 1 until valid.size) { + val gap = valid[i].y - filtered.last().y + if (gap > medianGap * 0.3 && gap < medianGap * 3.0) filtered.add(valid[i]) + } + return filtered + } + return valid + } +} diff --git a/app/src/main/java/com/example/medilightv2android/measure/VoidingRecord.kt b/app/src/main/java/com/example/medilightv2android/measure/VoidingRecord.kt new file mode 100644 index 0000000..899784c --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/measure/VoidingRecord.kt @@ -0,0 +1,78 @@ +package com.example.medilightv2android.measure + +import android.content.Context +import android.content.SharedPreferences +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.text.SimpleDateFormat +import java.util.* + +/** + * 배뇨 기록 데이터 + */ +data class VoidingRecord( + val id: String = UUID.randomUUID().toString(), + val volume: Int?, // ml (null이면 기상/취침) + val activity: String = "none", // "none", "awake", "sleep" + val method: String = "manual", // "manual", "voice", "camera" + val timestamp: Long = System.currentTimeMillis() +) { + val timeString: String + get() = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp)) + + val dateString: String + get() = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date(timestamp)) + + val displayText: String + get() = when { + volume != null -> "$timeString 소변량 $volume ml" + activity == "awake" -> "$timeString 기상" + activity == "sleep" -> "$timeString 취침" + else -> "$timeString 기록" + } +} + +/** + * 로컬 배뇨 기록 저장소 (SharedPreferences + JSON) + */ +object VoidingRecordStore { + private const val PREFS_NAME = "voiding_records" + private const val KEY_RECORDS = "records" + private val gson = Gson() + + private fun prefs(context: Context): SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun getAll(context: Context): List { + val json = prefs(context).getString(KEY_RECORDS, null) ?: return emptyList() + val type = object : TypeToken>() {}.type + return try { gson.fromJson(json, type) } catch (_: Exception) { emptyList() } + } + + fun add(context: Context, record: VoidingRecord) { + val records = getAll(context).toMutableList() + records.add(record) + save(context, records) + } + + fun delete(context: Context, id: String) { + val records = getAll(context).filter { it.id != id } + save(context, records) + } + + fun clear(context: Context) { + prefs(context).edit().remove(KEY_RECORDS).apply() + } + + private fun save(context: Context, records: List) { + prefs(context).edit().putString(KEY_RECORDS, gson.toJson(records)).apply() + } + + fun getToday(context: Context): List { + val todayStart = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) + }.timeInMillis + return getAll(context).filter { it.timestamp >= todayStart }.sortedBy { it.timestamp } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/measure/YoloDetector.kt b/app/src/main/java/com/example/medilightv2android/measure/YoloDetector.kt new file mode 100644 index 0000000..ef23ddb --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/measure/YoloDetector.kt @@ -0,0 +1,130 @@ +package com.example.medilightv2android.measure + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.RectF +import android.util.Log +import ai.onnxruntime.OnnxTensor +import ai.onnxruntime.OrtEnvironment +import ai.onnxruntime.OrtSession +import java.nio.FloatBuffer + +class YoloDetector(private val context: Context) { + + private var session: OrtSession? = null + private var ortEnv: OrtEnvironment? = null + private val inputSize = 640 + private var isLoaded = false + + data class DetectionResult( + val boundingBox: RectF, // Normalized [0,1], top-left origin + val confidence: Float, + val label: String + ) + + fun loadModel(): Boolean { + return try { + ortEnv = OrtEnvironment.getEnvironment() + val modelBytes = context.assets.open("urinecup_best.onnx").readBytes() + session = ortEnv!!.createSession(modelBytes) + isLoaded = true + Log.d(TAG, "ONNX YOLO model loaded successfully") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to load ONNX model", e) + isLoaded = false + false + } + } + + fun isModelLoaded(): Boolean = isLoaded + + fun detect(bitmap: Bitmap): DetectionResult? { + val session = this.session ?: return null + val env = this.ortEnv ?: return null + + try { + val resized = Bitmap.createScaledBitmap(bitmap, inputSize, inputSize, true) + + // Bitmap → float array [1, 3, 640, 640] NCHW, normalized 0-1 + val floatBuffer = FloatBuffer.allocate(1 * 3 * inputSize * inputSize) + val pixels = IntArray(inputSize * inputSize) + resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize) + + for (c in 0 until 3) { + for (i in pixels.indices) { + val pixel = pixels[i] + val value = when (c) { + 0 -> ((pixel shr 16) and 0xFF) / 255f // R + 1 -> ((pixel shr 8) and 0xFF) / 255f // G + 2 -> (pixel and 0xFF) / 255f // B + else -> 0f + } + floatBuffer.put(value) + } + } + floatBuffer.rewind() + + val shape = longArrayOf(1, 3, inputSize.toLong(), inputSize.toLong()) + val inputTensor = OnnxTensor.createTensor(env, floatBuffer, shape) + + val inputName = session.inputNames.first() + val results = session.run(mapOf(inputName to inputTensor)) + + // YOLOv8 output: [1, 5, 8400] + val outputTensor = results[0] as OnnxTensor + @Suppress("UNCHECKED_CAST") + val output = (outputTensor.value as Array>)[0] + + inputTensor.close() + results.close() + + return parseYoloOutput(output) + } catch (e: Exception) { + Log.e(TAG, "YOLO detection failed", e) + return null + } + } + + private fun parseYoloOutput(output: Array): DetectionResult? { + // output: [5, 8400] → rows = cx, cy, w, h, conf + val numDetections = output[0].size + var bestConf = 0.25f + var bestBox: FloatArray? = null + + for (i in 0 until numDetections) { + val conf = output[4][i] + if (conf > bestConf) { + bestConf = conf + val cx = output[0][i] / inputSize + val cy = output[1][i] / inputSize + val w = output[2][i] / inputSize + val h = output[3][i] / inputSize + bestBox = floatArrayOf(cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2) + } + } + + return bestBox?.let { + DetectionResult( + boundingBox = RectF( + it[0].coerceIn(0f, 1f), + it[1].coerceIn(0f, 1f), + it[2].coerceIn(0f, 1f), + it[3].coerceIn(0f, 1f) + ), + confidence = bestConf, + label = "urinecup" + ) + } + } + + fun close() { + session?.close() + session = null + isLoaded = false + } + + companion object { + private const val TAG = "YoloDetector" + } +} diff --git a/app/src/main/java/com/example/medilightv2android/speech/HfVolumeExtractor.kt b/app/src/main/java/com/example/medilightv2android/speech/HfVolumeExtractor.kt new file mode 100644 index 0000000..88f0296 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/speech/HfVolumeExtractor.kt @@ -0,0 +1,298 @@ +/* + * Copyright 2026 Charles KWON (KWON Ohjun) + * charleskwon@medithings.co.kr / charleskwonohjun@gmail.com + * MEDiThings Inc. + * All rights reserved. + */ +package com.example.medilightv2android.speech + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +/** + * Extracts a milliliter volume from speech-recognition text via the + * Hugging Face Inference API (Qwen 2.5-72B model). + * + * Selects a language-specific system prompt to maximize extraction accuracy. + * Falls back to [VolumeParser] when the API key is missing or the request fails. + * + * @param apiKey Hugging Face bearer token. + */ +class HfVolumeExtractor(private val apiKey: String) { + + /** Successful extraction result. */ + data class ExtractResult( + val volumeMl: Int, + val rawText: String, + val explanation: String, + ) + + private val client = OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + /** + * Send [speechText] to the AI model using a prompt tailored to [language], + * and return the extracted volume, or `null` when extraction fails. + */ + suspend fun extract( + speechText: String, + language: SpeechLanguage = SpeechLanguage.KOREAN, + ): ExtractResult? = withContext(Dispatchers.IO) { + Log.d(TAG, "Starting extraction | lang=${language.code} | input: $speechText") + + if (apiKey.isBlank()) { + Log.w(TAG, "API key is blank; falling back to local parser") + return@withContext fallbackParse(speechText) + } + + try { + val prompt = getSystemPrompt(language) + val requestBody = buildRequestBody(speechText, prompt) + + val request = Request.Builder() + .url(API_URL) + .addHeader("Authorization", "Bearer $apiKey") + .addHeader("Content-Type", JSON_MEDIA_TYPE) + .post(requestBody.toRequestBody(JSON_MEDIA_TYPE.toMediaType())) + .build() + + val response = client.newCall(request).execute() + val body = response.body?.string() ?: return@withContext null + + Log.d(TAG, "Response code: ${response.code}") + + if (!response.isSuccessful) { + Log.e(TAG, "API failed (${response.code}); falling back to local parser") + return@withContext fallbackParse(speechText) + } + + parseApiResponse(body, speechText) + } catch (e: Exception) { + Log.e(TAG, "API error: ${e.message}", e) + fallbackParse(speechText) + } + } + + // ── Private helpers ───────────────────────────────────────────────── + + private fun buildRequestBody(speechText: String, systemPrompt: String): String = + JSONObject().apply { + put("model", MODEL_ID) + put("max_tokens", MAX_TOKENS) + put("stream", false) + put("messages", JSONArray().apply { + put(JSONObject().apply { + put("role", "system") + put("content", systemPrompt) + }) + put(JSONObject().apply { + put("role", "user") + put("content", speechText) + }) + }) + }.toString() + + private fun parseApiResponse(body: String, speechText: String): ExtractResult? { + val content = JSONObject(body) + .getJSONArray("choices") + .getJSONObject(0) + .getJSONObject("message") + .getString("content") + + Log.d(TAG, "Model response: $content") + + val jsonStr = extractJsonObject(content) + val resultJson = JSONObject(jsonStr) + val ml = resultJson.getInt("ml") + val explanation = resultJson.optString("explanation", "") + + Log.d(TAG, "Parsed result: ${ml}ml - $explanation") + + return if (ml in VALID_RANGE) { + ExtractResult(ml, speechText, explanation) + } else { + Log.w(TAG, "Volume out of range: ${ml}ml") + null + } + } + + private fun extractJsonObject(text: String): String { + val start = text.indexOf('{') + val end = text.lastIndexOf('}') + return if (start >= 0 && end > start) text.substring(start, end + 1) else text + } + + private fun fallbackParse(text: String): ExtractResult? { + val result = VolumeParser.parse(text) ?: return null + Log.d(TAG, "Local parse result: ${result.volumeMl}ml") + return ExtractResult(result.volumeMl, result.rawText, "Local parse") + } + + /** Return a system prompt optimized for the given [language]. */ + private fun getSystemPrompt(language: SpeechLanguage): String = when (language) { + SpeechLanguage.KOREAN -> PROMPT_KOREAN + SpeechLanguage.ENGLISH -> PROMPT_ENGLISH + SpeechLanguage.CHINESE -> PROMPT_CHINESE + SpeechLanguage.JAPANESE -> PROMPT_JAPANESE + } + + companion object { + private const val TAG = "HfVolumeExtractor" + private const val API_URL = "https://router.huggingface.co/v1/chat/completions" + private const val MODEL_ID = "Qwen/Qwen2.5-72B-Instruct" + private const val JSON_MEDIA_TYPE = "application/json" + private const val MAX_TOKENS = 100 + private const val CONNECT_TIMEOUT_SECONDS = 15L + private const val READ_TIMEOUT_SECONDS = 30L + private val VALID_RANGE = 1..1000 + + // ── Language-specific prompts ─────────────────────────────────── + + private val PROMPT_BASE = """ + ## Your Mission + You are specialized in extracting MILLILITER (ml) volumes from voice input. + The text you receive is raw speech-to-text output from a medical/lab device. + Your ONLY job: find the ml number the user intended to say. + + ## Rules + - Extract a single integer in ml, range 1~1000 + - mm and ml are the SAME in this context (user ALWAYS means ml, never millimeters) + - cc and ml are the SAME (1cc = 1ml) + - If ambiguous, prefer the most likely medical volume + - Look at ALL candidates (separated by " / ") to find consensus on the number + - If no valid volume found, return -1 + - Respond ONLY in this JSON format: + """.trimIndent() + + private val PROMPT_KOREAN = """ + You are a milliliter (ml) volume extraction AI. + + ## Context + This is a KOREAN speech-to-text result from a medical/lab voice input device. + The user spoke in KOREAN to input a liquid volume in milliliters (ml). + Your task: understand Korean speech and extract the ml value accurately. + + ## Input Format + Multiple STT candidates separated by " / " (e.g. "95mm / 95ml / 95 mm"). + STT often confuses mm/ml/미리/밀리 — the user ALWAYS means milliliters (ml). + + ## Korean-Specific Patterns + - Pure Korean numerals: "삼백 밀리", "백오십 미리" + - Numbers with units: "300ml", "150 미리" + - Korean-English mix: "삼백 ml", "원헌드레드 미리" + - Misheard Korean that is actually English: "쓰리헌드레드", "투헌드레드 피프티" + - Phonetic Korean of English: "파이브 헌드레드" = 500, "투 피프티" = 250 + - Casual/slang: "반 리터" = 500, "한 컵" = 250, "반" = 500, "쿼터" = 250 + - Unit variations: 밀리리터, 밀리미터, 미리, 밀리, ml, cc, 시시 + + ## Critical: Korean Phonetic Analysis + Korean STT may transcribe English words as Korean phonetics. + SOUND THEM OUT to decode: + - 쓰리 = three, 투 = two, 원 = one, 포 = four, 파이브 = five + - 식스 = six, 세븐 = seven, 에잇 = eight, 나인 = nine, 텐 = ten + - 헌드레드 = hundred, 피프티 = fifty, 트웬티 = twenty, 서티 = thirty + - 지로/제로 = zero, 오 = five OR "o" (context dependent) + - 더블 = double, 트리플 = triple + + $PROMPT_BASE + {"ml": , "explanation": "<한국어로 간단한 설명>"} + """.trimIndent() + + private val PROMPT_ENGLISH = """ + You are a milliliter (ml) volume extraction AI. + + ## Context + This is an ENGLISH speech-to-text result from a medical/lab voice input device. + The user spoke in ENGLISH to input a liquid volume in milliliters (ml). + Your task: understand English speech and extract the ml value accurately. + + ## Input Format + Multiple STT candidates separated by " / " (e.g. "95mm / 95ml / 95 mm"). + STT often confuses mm/ml — the user ALWAYS means milliliters (ml). + + ## English-Specific Patterns + - Standard: "three hundred milliliters", "one fifty ml" + - Digit-by-digit: "three zero zero" = 300, "one five o" = 150, "two o five" = 205 + - Casual: "half a liter" = 500, "a quarter liter" = 250, "half" = 500 + - Compound: "one hundred and fifty", "two fifty" + - With modifiers: "double zero" = 00, "triple zero" = 000 + - Unit variations: ml, milliliters, millimeters, cc + + $PROMPT_BASE + {"ml": , "explanation": ""} + """.trimIndent() + + private val PROMPT_CHINESE = """ + 你是一个毫升(ml)容量提取AI。 + + ## 背景 + 这是来自医疗/实验室语音输入设备的中文语音识别结果。 + 用户用中文说出了一个液体容量(毫升/ml)。 + 你的任务:准确理解中文语音并提取毫升值。 + + ## 输入格式 + 多个语音识别候选项用 " / " 分隔(例如 "95毫升 / 95ml / 九十五")。 + 语音识别经常混淆毫米/毫升——用户始终指的是毫升(ml)。 + + ## 中文特有模式 + - 中文数字: "三百毫升", "一百五十", "二百五" + - 阿拉伯数字+中文单位: "300毫升", "150ml" + - 口语: "半升" = 500, "一杯" = 250, "半" = 500 + - 口语简称: "二百五" = 250(此处不是骂人), "三百" = 300 + - 单位变体: 毫升, 毫米, ml, cc, 西西 + + ## 关键:中文数字规则 + - 一百五 = 150(一百五十的简称) + - 二百五 = 250(二百五十的简称) + - 两百 = 200 (两 = 2 in spoken Chinese) + - 半升 = 500ml, 四分之一升 = 250ml + - 一千 = 1000 + + $PROMPT_BASE + {"ml": , "explanation": "<用中文简要说明>"} + """.trimIndent() + + private val PROMPT_JAPANESE = """ + あなたはミリリットル(ml)容量抽出AIです。 + + ## 背景 + これは医療・実験室の音声入力デバイスからの日本語音声認識結果です。 + ユーザーは日本語で液体の容量(ミリリットル/ml)を発話しました。 + あなたのタスク:日本語の音声を正確に理解し、ml値を抽出すること。 + + ## 入力形式 + 複数の音声認識候補が " / " で区切られています(例:"95ミリ / 95ml / きゅうじゅうご")。 + 音声認識はミリメートル/ミリリットルを混同することが多い — ユーザーは常にミリリットル(ml)を意味します。 + + ## 日本語特有のパターン + - 日本語数字: "さんびゃくミリ", "ひゃくごじゅう" + - 漢数字: "三百ミリリットル", "百五十" + - 数字+単位: "300ml", "150ミリ" + - カジュアル: "半リットル" = 500, "コップ一杯" = 250 + - 省略形: "にひゃくごじゅう" = 250, "さんびゃく" = 300 + - 単位バリエーション: ミリリットル, ミリ, ml, cc, シーシー + + ## 重要:日本語数字ルール + - ひゃくごじゅう = 150 + - にひゃく = 200 + - にひゃくご = 250(にひゃくごじゅうの略) + - さんびゃく = 300 + - ごひゃく = 500 + - せん = 1000 + - 半分 = half (context: 半リットル = 500ml) + + $PROMPT_BASE + {"ml": , "explanation": "<日本語で簡単な説明>"} + """.trimIndent() + } +} diff --git a/app/src/main/java/com/example/medilightv2android/speech/SpeechRecognizerManager.kt b/app/src/main/java/com/example/medilightv2android/speech/SpeechRecognizerManager.kt new file mode 100644 index 0000000..89452a7 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/speech/SpeechRecognizerManager.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2026 Charles KWON (KWON Ohjun) + * charleskwon@medithings.co.kr / charleskwonohjun@gmail.com + * MEDiThings Inc. + * All rights reserved. + */ +package com.example.medilightv2android.speech + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer + +/** + * Supported speech-recognition locales. + * + * @property code BCP-47 language tag used by [RecognizerIntent]. + * @property label Human-readable name shown in the UI language toggle. + */ +enum class SpeechLanguage(val code: String, val label: String) { + KOREAN("ko-KR", "한국어"), + ENGLISH("en-US", "English"), + CHINESE("zh-CN", "中文"), + JAPANESE("ja-JP", "日本語"), +} + +/** + * Thin wrapper around Android's [SpeechRecognizer] that manages the recognizer lifecycle + * and exposes results through simple callbacks. + * + * @param context Application or Activity context. + * @param language Target speech-recognition locale. + * @param onResult Invoked with the ranked list of recognition candidates. + * @param onError Invoked with a [SpeechRecognizer] error code on failure. + * @param onReady Invoked when the recognizer is ready to accept speech. + */ +class SpeechRecognizerManager( + private val context: Context, + private val language: SpeechLanguage = SpeechLanguage.KOREAN, + private val onResult: (List) -> Unit, + private val onError: (Int) -> Unit, + private val onReady: () -> Unit, +) { + + private var speechRecognizer: SpeechRecognizer? = null + + private val recognitionIntent: Intent by lazy { + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, language.code) + putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, language.code) + putExtra(RecognizerIntent.EXTRA_ONLY_RETURN_LANGUAGE_PREFERENCE, false) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, MAX_RESULTS) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + } + } + + /** Create a fresh [SpeechRecognizer] and begin listening. */ + fun startListening() { + destroy() + + speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context).apply { + setRecognitionListener(createListener()) + startListening(recognitionIntent) + } + } + + /** Ask the recognizer to stop capturing audio. */ + fun stopListening() { + speechRecognizer?.stopListening() + } + + /** Release the underlying [SpeechRecognizer] resources. */ + fun destroy() { + speechRecognizer?.destroy() + speechRecognizer = null + } + + private fun createListener(): RecognitionListener = object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { onReady() } + override fun onBeginningOfSpeech() {} + override fun onRmsChanged(rmsdB: Float) {} + override fun onBufferReceived(buffer: ByteArray?) {} + override fun onEndOfSpeech() {} + override fun onError(error: Int) { this@SpeechRecognizerManager.onError(error) } + + override fun onResults(results: Bundle?) { + val matches = results + ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + .orEmpty() + onResult(matches) + } + + override fun onPartialResults(partialResults: Bundle?) {} + override fun onEvent(eventType: Int, params: Bundle?) {} + } + + companion object { + private const val MAX_RESULTS = 5 + } +} diff --git a/app/src/main/java/com/example/medilightv2android/speech/VolumeParser.kt b/app/src/main/java/com/example/medilightv2android/speech/VolumeParser.kt new file mode 100644 index 0000000..7eee4ef --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/speech/VolumeParser.kt @@ -0,0 +1,260 @@ +/* + * Copyright 2026 Charles KWON (KWON Ohjun) + * charleskwon@medithings.co.kr / charleskwonohjun@gmail.com + * MEDiThings Inc. + * All rights reserved. + */ +package com.example.medilightv2android.speech + +/** + * Local (offline) parser that extracts a volume in milliliters from speech-recognition text. + * + * Supported patterns: + * - Arabic digits with unit: "150ml", "200 미리" + * - Korean numerals with unit: "백오십 밀리리터", "이백 미리" + * - English numerals with unit: "one hundred fifty ml", "two hundred milliliters" + * - English digit-by-digit: "three zero zero" -> 300, "one five o" -> 150, "two o five" -> 205 + * - Bare numbers (no unit): "삼백", "250", "three hundred" + */ +object VolumeParser { + + /** Result of a successful volume parse. */ + data class ParseResult( + val volumeMl: Int, + val rawText: String, + ) + + // ── Korean number mappings (DO NOT translate these Korean keys) ────── + + private val DIGIT_MAP = mapOf( + "영" to 0, "공" to 0, + "일" to 1, "하나" to 1, "한" to 1, + "이" to 2, "둘" to 2, "두" to 2, + "삼" to 3, "셋" to 3, "세" to 3, + "사" to 4, "넷" to 4, "네" to 4, + "오" to 5, "다섯" to 5, + "육" to 6, "여섯" to 6, + "칠" to 7, "일곱" to 7, + "팔" to 8, "여덟" to 8, + "구" to 9, "아홉" to 9, + ) + + private val PLACE_MAP = mapOf( + "십" to 10, + "백" to 100, + "천" to 1000, + ) + + private val UNIT_KEYWORDS = listOf( + "milliliters", "milliliter", "millimeters", "millimeter", + "밀리리터", "밀리미터", "미리리터", "미리미터", + "미리", "밀리", "ml", "ML", "mL", + ) + + // ── English number mappings ───────────────────────────────────────── + + private val EN_ONES = mapOf( + "zero" to 0, "one" to 1, "two" to 2, "three" to 3, "four" to 4, + "five" to 5, "six" to 6, "seven" to 7, "eight" to 8, "nine" to 9, + "ten" to 10, "eleven" to 11, "twelve" to 12, "thirteen" to 13, + "fourteen" to 14, "fifteen" to 15, "sixteen" to 16, "seventeen" to 17, + "eighteen" to 18, "nineteen" to 19, + ) + + private val EN_TENS = mapOf( + "twenty" to 20, "thirty" to 30, "forty" to 40, "fifty" to 50, + "sixty" to 60, "seventy" to 70, "eighty" to 80, "ninety" to 90, + ) + + /** Single-digit mapping for digit-by-digit reading (includes "o"/"oh" as zero). */ + private val EN_SINGLE_DIGIT = mapOf( + "zero" to 0, "o" to 0, "oh" to 0, + "one" to 1, "two" to 2, "three" to 3, "four" to 4, + "five" to 5, "six" to 6, "seven" to 7, "eight" to 8, "nine" to 9, + ) + + // ── Public API ────────────────────────────────────────────────────── + + /** + * Attempt to extract a volume (1..1000 ml) from [text]. + * + * @return [ParseResult] when a valid volume is found, `null` otherwise. + */ + fun parse(text: String): ParseResult? { + val cleaned = text.trim() + if (cleaned.isEmpty()) return null + + // 1) Arabic digits (most reliable) + extractArabicNumber(cleaned)?.takeIf { it in VALID_RANGE }?.let { + return ParseResult(it, cleaned) + } + + // 2) Korean numerals (checked before English to avoid "오" / "o" confusion) + extractKoreanNumber(cleaned)?.takeIf { it in VALID_RANGE }?.let { + return ParseResult(it, cleaned) + } + + // 3) English compound numerals ("one hundred fifty") + extractEnglishNumber(cleaned)?.takeIf { it in VALID_RANGE }?.let { + return ParseResult(it, cleaned) + } + + // 4) English digit-by-digit ("three zero zero") -- last because "o" maps to 0 + extractDigitByDigitEnglish(cleaned)?.takeIf { it in VALID_RANGE }?.let { + return ParseResult(it, cleaned) + } + + return null + } + + /** + * Try each [candidates] in order and return the first successful parse. + * + * Useful because [android.speech.SpeechRecognizer] may return multiple hypotheses. + */ + fun parseBest(candidates: List): ParseResult? = + candidates.firstNotNullOfOrNull { parse(it) } + + // ── Extraction strategies ─────────────────────────────────────────── + + private fun extractArabicNumber(text: String): Int? { + val stripped = removeUnits(text).replace(Regex("[^0-9]"), "") + return stripped.toIntOrNull() + } + + private fun extractKoreanNumber(text: String): Int? { + val stripped = removeUnits(text) + .replace(Regex("[0-9mlML\\s]", RegexOption.IGNORE_CASE), "") + .trim() + if (stripped.isEmpty()) return null + return parseKoreanNumberString(stripped) + } + + /** + * Convert a Korean numeral string to an integer. + * Example: "백오십" -> 150, "이백삼" -> 203, "삼백" -> 300 + */ + private fun parseKoreanNumberString(text: String): Int? { + var result = 0 + var current = 0 + var i = 0 + val sortedDigits = DIGIT_MAP.entries.sortedByDescending { it.key.length } + + while (i < text.length) { + // Try place-value keywords first (십, 백, 천) + val placeMatch = PLACE_MAP.entries.firstOrNull { text.startsWith(it.key, i) } + if (placeMatch != null) { + if (current == 0) current = 1 + result += current * placeMatch.value + current = 0 + i += placeMatch.key.length + continue + } + + // Try digit words (longest match first: "다섯" before "다") + val digitMatch = sortedDigits.firstOrNull { text.startsWith(it.key, i) } + if (digitMatch != null) { + current = digitMatch.value + i += digitMatch.key.length + continue + } + + // Unrecognized character -- skip + i++ + } + + result += current + return result.takeIf { it > 0 } + } + + /** + * Convert an English numeral phrase to an integer. + * Example: "one hundred fifty" -> 150, "two hundred" -> 200 + */ + private fun extractEnglishNumber(text: String): Int? { + val words = tokenize(text) + if (words.isEmpty()) return null + + var result = 0 + var current = 0 + + for (word in words) { + when { + word == "thousand" -> { + if (current == 0) current = 1 + current *= 1_000 + } + word == "hundred" -> { + if (current == 0) current = 1 + current *= 100 + } + word == "and" -> continue + word in EN_ONES -> current += EN_ONES.getValue(word) + word in EN_TENS -> current += EN_TENS.getValue(word) + else -> { + // Handle hyphenated forms: "twenty-five" + val parts = word.split("-") + if (parts.size == 2) { + val tens = EN_TENS[parts[0]] + val ones = EN_ONES[parts[1]] + if (tens != null && ones != null) current += tens + ones + } + } + } + } + + result += current + return result.takeIf { it > 0 } + } + + /** + * Convert digit-by-digit English reading to an integer. + * Example: "three zero zero" -> 300, "one five o" -> 150 + * Supports "double" / "triple" modifiers. + */ + private fun extractDigitByDigitEnglish(text: String): Int? { + val words = tokenize(text) + if (words.isEmpty()) return null + + // Expand "double"/"triple" modifiers + val expanded = buildList { + var i = 0 + while (i < words.size) { + when { + words[i] == "double" && i + 1 < words.size -> { + repeat(2) { add(words[i + 1]) } + i += 2 + } + words[i] == "triple" && i + 1 < words.size -> { + repeat(3) { add(words[i + 1]) } + i += 2 + } + else -> { + add(words[i]) + i++ + } + } + } + } + + // Every token must be a single digit + val digits = expanded.map { EN_SINGLE_DIGIT[it] ?: return null } + if (digits.size !in 2..4) return null + + val result = digits.fold(0) { acc, d -> acc * 10 + d } + return result.takeIf { it > 0 } + } + + // ── Utilities ─────────────────────────────────────────────────────── + + private fun removeUnits(text: String): String = + UNIT_KEYWORDS.fold(text) { acc, unit -> acc.replace(unit, "", ignoreCase = true) }.trim() + + /** Lowercase, strip units and non-alpha characters, then split on whitespace. */ + private fun tokenize(text: String): List { + val stripped = removeUnits(text).lowercase().replace(Regex("[^a-z\\s-]"), "").trim() + return stripped.split(Regex("\\s+")).filter { it.isNotEmpty() } + } + + private val VALID_RANGE = 1..1000 +} diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/CatheterizeSheet.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/CatheterizeSheet.kt new file mode 100644 index 0000000..218a5c7 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/CatheterizeSheet.kt @@ -0,0 +1,207 @@ +package com.example.medilightv2android.ui.views.monitoring + +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import com.example.medilightv2android.measure.VoidingRecord +import com.example.medilightv2android.measure.VoidingRecordStore +import com.example.medilightv2android.speech.SpeechRecognizerManager +import com.example.medilightv2android.speech.VolumeParser + +/** + * Catheterize 바텀시트 — 수기/음성/카메라 3가지 입력 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatheterizeSheet( + onDismiss: () -> Unit, + onCameraRequested: () -> Unit, + onCatheterized: () -> Unit // 레벨 리셋 콜백 +) { + val context = LocalContext.current + var selectedTab by remember { mutableIntStateOf(0) } // 0=수기, 1=음성, 2=카메라 + var volumeText by remember { mutableStateOf("") } + var isSaved by remember { mutableStateOf(false) } + + // 음성 인식 + var isListening by remember { mutableStateOf(false) } + var voiceStatus by remember { mutableStateOf("") } + var speechManager by remember { mutableStateOf(null) } + var hasAudioPerm by remember { + mutableStateOf(ContextCompat.checkSelfPermission(context, android.Manifest.permission.RECORD_AUDIO) == + android.content.pm.PackageManager.PERMISSION_GRANTED) + } + val audioPermLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { hasAudioPerm = it } + + DisposableEffect(Unit) { onDispose { speechManager?.destroy() } } + + fun saveRecord(volume: Int?, method: String) { + VoidingRecordStore.add(context, VoidingRecord(volume = volume, method = method)) + onCatheterized() + isSaved = true + Toast.makeText(context, "배뇨일지에 등록되었습니다", Toast.LENGTH_SHORT).show() + } + + fun startVoice() { + if (!hasAudioPerm) { audioPermLauncher.launch(android.Manifest.permission.RECORD_AUDIO); return } + speechManager?.destroy() + isListening = true; voiceStatus = "듣는 중..." + speechManager = SpeechRecognizerManager( + context = context, + onResult = { candidates -> + isListening = false + // 범위 표현 + val rangeRegex = Regex("""(\d{2,3})\s*(?:에서|~|부터|-)\s*(\d{2,3})\s*(?:사이|까지)?""") + for (c in candidates) { + rangeRegex.find(c)?.let { m -> + val lo = m.groupValues[1].toIntOrNull(); val hi = m.groupValues[2].toIntOrNull() + if (lo != null && hi != null && lo in 1..1000 && hi in 1..1000) { + val avg = (lo + hi) / 2 + volumeText = avg.toString() + voiceStatus = "\"$lo~$hi\" → ${avg} ml" + saveRecord(avg, "voice") + return@SpeechRecognizerManager + } + } + } + VolumeParser.parseBest(candidates)?.let { + volumeText = it.volumeMl.toString() + voiceStatus = "\"${it.rawText}\" → ${it.volumeMl} ml" + saveRecord(it.volumeMl, "voice") + } ?: run { voiceStatus = "인식 실패: ${candidates.firstOrNull() ?: ""}" } + }, + onError = { code -> isListening = false; voiceStatus = when(code) { + android.speech.SpeechRecognizer.ERROR_NO_MATCH -> "인식된 음성 없음" + android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "시간 초과" + else -> "오류 ($code)" + }}, + onReady = { voiceStatus = "말씀하세요..." } + ) + speechManager?.startListening() + } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 24.dp).padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isSaved) { + // 저장 완료 화면 + Icon(Icons.Default.CheckCircle, null, Modifier.size(64.dp), tint = Color(0xFF4CAF50)) + Spacer(Modifier.height(16.dp)) + Text("배뇨일지에 등록되었습니다", fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + if (volumeText.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text("$volumeText ml", fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color(0xFF007AFF)) + } + Spacer(Modifier.height(24.dp)) + Button( + onClick = onDismiss, + Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(12.dp) + ) { Text("확인") } + } else { + // 입력 모드 선택 + Text("배뇨 기록", fontSize = 20.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(16.dp)) + + // 3 탭 버튼 + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + Triple("수기", Icons.Default.Edit, Color(0xFFFF9800)), + Triple("음성", Icons.Default.SettingsVoice, Color(0xFF007AFF)), + Triple("카메라", Icons.Default.CameraAlt, Color(0xFF4CAF50)) + ).forEachIndexed { idx, (label, icon, color) -> + val isSelected = selectedTab == idx + OutlinedButton( + onClick = { + selectedTab = idx + if (idx == 2) { onCameraRequested(); onDismiss() } + }, + modifier = Modifier.weight(1f).height(64.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (isSelected) color.copy(alpha = 0.1f) else Color.Transparent + ) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(icon, null, tint = color, modifier = Modifier.size(20.dp)) + Text(label, fontSize = 11.sp, color = color, fontWeight = FontWeight.SemiBold) + } + } + } + } + + Spacer(Modifier.height(20.dp)) + + when (selectedTab) { + 0 -> { + // 수기 입력 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + OutlinedTextField( + value = volumeText, + onValueChange = { volumeText = it.filter { c -> c.isDigit() }.take(4) }, + placeholder = { Text("예: 200", color = Color.Gray) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 32.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), + modifier = Modifier.width(160.dp), + shape = RoundedCornerShape(12.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("ml", fontSize = 24.sp, fontWeight = FontWeight.SemiBold, color = Color.Gray) + } + Spacer(Modifier.height(20.dp)) + val vol = volumeText.toIntOrNull() + Button( + onClick = { if (vol != null && vol in 1..1000) saveRecord(vol, "manual") }, + enabled = vol != null && vol in 1..1000, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFF9800)) + ) { Text("저장하기", fontWeight = FontWeight.SemiBold) } + } + 1 -> { + // 음성 입력 + Button( + onClick = { startVoice() }, + modifier = Modifier.size(80.dp), + shape = RoundedCornerShape(40.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isListening) Color(0xFFE53935) else Color(0xFF007AFF) + ) + ) { + Icon(Icons.Default.SettingsVoice, null, Modifier.size(36.dp), tint = Color.White) + } + Spacer(Modifier.height(12.dp)) + Text( + if (isListening) "듣는 중..." else "탭하여 음성 입력", + color = Color.Gray, fontSize = 14.sp + ) + if (voiceStatus.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text(voiceStatus, color = Color.Gray, fontSize = 12.sp) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index c05bab4..e5e4c63 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -44,6 +44,7 @@ fun PiezoMonitoringView(appState: AppState) { var showSettings by remember { mutableStateOf(false) } var isMeasuring by remember { mutableStateOf(false) } var showCatheterizeConfirm by remember { mutableStateOf(false) } + var showCatheterizeSheet by remember { mutableStateOf(false) } var showAddCatheterDialog by remember { mutableStateOf(false) } var showDisconnectionBanner by remember { mutableStateOf(false) } var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) } @@ -111,7 +112,7 @@ fun PiezoMonitoringView(appState: AppState) { ) } - // Catheterize confirmation dialog + // 기존 Catheterize 확인 다이얼로그 (레거시 유지) if (showCatheterizeConfirm) { AlertDialog( onDismissRequest = { showCatheterizeConfirm = false }, @@ -129,6 +130,19 @@ fun PiezoMonitoringView(appState: AppState) { ) } + // 배뇨 기록 시트 (수기/음성/카메라) + if (showCatheterizeSheet) { + CatheterizeSheet( + onDismiss = { showCatheterizeSheet = false }, + onCameraRequested = { + // TODO: 카메라 화면으로 이동 (UrineCameraScreen 추가 필요) + }, + onCatheterized = { + appState.resetLevel() + } + ) + } + Column( modifier = Modifier .fillMaxSize() @@ -299,9 +313,9 @@ fun PiezoMonitoringView(appState: AppState) { // Control buttons Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - // Catheterize button + // Catheterize button → 배뇨 기록 시트 Button( - onClick = { showCatheterizeConfirm = true }, + onClick = { showCatheterizeSheet = true }, modifier = Modifier.weight(1f).height(56.dp), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.1f))