Integrate voiding diary into Piezo catheterization flow
Catheterize button now opens a voiding record sheet with 3 input modes: - Manual: number TextField (1-1000ml) - Voice: SpeechRecognizer + VolumeParser (Korean/English) - Camera: placeholder for YOLO cup measurement (TODO) On save: "배뇨일지에 등록되었습니다" toast, bladder level resets to 0 New modules (from Uridiary): - measure/: SimpleMeasureService, YoloDetector, CCPosition, VoidingRecord - speech/: SpeechRecognizerManager, VolumeParser, HfVolumeExtractor - assets/urinecup_best.onnx: YOLO model for cup detection VoidingRecordStore: local SharedPreferences+JSON storage for voiding records Dependencies added: ONNX Runtime, ML Kit OCR, CameraX, OkHttp Permissions added: CAMERA, RECORD_AUDIO, INTERNET Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,16 @@ dependencies {
|
|||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
implementation(libs.rive.android)
|
implementation(libs.rive.android)
|
||||||
implementation(libs.androidx.startup.runtime)
|
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)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -13,6 +13,16 @@
|
|||||||
<!-- Notification Permission (Android 13+) -->
|
<!-- Notification Permission (Android 13+) -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- Camera (소변컵 촬영) -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
|
<!-- Voice Recognition -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<!-- Internet (AI 음성 fallback) -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<!-- BLE feature -->
|
<!-- BLE feature -->
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
|
)
|
||||||
@@ -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<Pair<Int, Float>>()
|
||||||
|
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<Pair<Int, Float>> {
|
||||||
|
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<Pair<Int, Float>>()
|
||||||
|
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<CCPosition>): 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<CCPosition> =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
|
||||||
|
recognizer.process(InputImage.fromBitmap(bitmap, 0))
|
||||||
|
.addOnSuccessListener { visionText ->
|
||||||
|
val positions = mutableListOf<CCPosition>()
|
||||||
|
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<CCPosition>): List<CCPosition> {
|
||||||
|
if (raw.size < 2) return raw
|
||||||
|
val sortedByY = raw.sortedBy { it.y }
|
||||||
|
val valid = mutableListOf<CCPosition>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<VoidingRecord> {
|
||||||
|
val json = prefs(context).getString(KEY_RECORDS, null) ?: return emptyList()
|
||||||
|
val type = object : TypeToken<List<VoidingRecord>>() {}.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<VoidingRecord>) {
|
||||||
|
prefs(context).edit().putString(KEY_RECORDS, gson.toJson(records)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getToday(context: Context): List<VoidingRecord> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Array<FloatArray>>)[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<FloatArray>): 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": <integer>, "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": <integer>, "explanation": "<brief reason in English>"}
|
||||||
|
""".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": <integer>, "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": <integer>, "explanation": "<日本語で簡単な説明>"}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>): 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<String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
+207
@@ -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<SpeechRecognizerManager?>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-3
@@ -44,6 +44,7 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
var showSettings by remember { mutableStateOf(false) }
|
var showSettings by remember { mutableStateOf(false) }
|
||||||
var isMeasuring by remember { mutableStateOf(false) }
|
var isMeasuring by remember { mutableStateOf(false) }
|
||||||
var showCatheterizeConfirm by remember { mutableStateOf(false) }
|
var showCatheterizeConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showCatheterizeSheet by remember { mutableStateOf(false) }
|
||||||
var showAddCatheterDialog by remember { mutableStateOf(false) }
|
var showAddCatheterDialog by remember { mutableStateOf(false) }
|
||||||
var showDisconnectionBanner by remember { mutableStateOf(false) }
|
var showDisconnectionBanner by remember { mutableStateOf(false) }
|
||||||
var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) }
|
var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) }
|
||||||
@@ -111,7 +112,7 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catheterize confirmation dialog
|
// 기존 Catheterize 확인 다이얼로그 (레거시 유지)
|
||||||
if (showCatheterizeConfirm) {
|
if (showCatheterizeConfirm) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showCatheterizeConfirm = false },
|
onDismissRequest = { showCatheterizeConfirm = false },
|
||||||
@@ -129,6 +130,19 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 배뇨 기록 시트 (수기/음성/카메라)
|
||||||
|
if (showCatheterizeSheet) {
|
||||||
|
CatheterizeSheet(
|
||||||
|
onDismiss = { showCatheterizeSheet = false },
|
||||||
|
onCameraRequested = {
|
||||||
|
// TODO: 카메라 화면으로 이동 (UrineCameraScreen 추가 필요)
|
||||||
|
},
|
||||||
|
onCatheterized = {
|
||||||
|
appState.resetLevel()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -299,9 +313,9 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
|
|
||||||
// Control buttons
|
// Control buttons
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
// Catheterize button
|
// Catheterize button → 배뇨 기록 시트
|
||||||
Button(
|
Button(
|
||||||
onClick = { showCatheterizeConfirm = true },
|
onClick = { showCatheterizeSheet = true },
|
||||||
modifier = Modifier.weight(1f).height(56.dp),
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.1f))
|
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.1f))
|
||||||
|
|||||||
Reference in New Issue
Block a user