UI: 피드백 반영 — Placement/Donut/뒤로가기/토스트

[Placement]
- 복부 높이: 500dp→weight(1f) 유동, 스크롤 제거
- 화살표 크기: 50→70, head 20→28, stroke 7→9
- 6/6, 60% 비개발자 모드에서 제거
- Step indicator를 이미지 위로 이동

[Donut]
- Bladdy 크기: 80/110dp→120/150dp (비개발자)
- 도넛 크기: 260/320dp→300/360dp (비개발자)
- 볼륨 텍스트 배경 제거 (투명)
- 원 내부 흰색 반투명 배경 추가
- measuring 메시지 제거 (Auto 대기 중 표시 안 함)
- 배터리 옆 초록 connection dot 제거
- Volume 카드 제거, Fill/Level만 유지 (maxVolumeMl 기준)
- "빠른 저장"→"Quick Save" 영어 통일
- 측정 부족/미감지 토스트 제거

[기타]
- HOME 뒤로가기 → "Exit App?" 확인 다이얼로그

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 12:36:41 +09:00
parent 414ce940a2
commit 6dd0ec5058
3 changed files with 128 additions and 128 deletions
@@ -11,8 +11,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import app.rive.runtime.kotlin.core.Rive import app.rive.runtime.kotlin.core.Rive
import com.example.medilightv2android.services.KeychainService import com.example.medilightv2android.services.KeychainService
@@ -62,7 +66,30 @@ fun MedilightApp(modifier: Modifier = Modifier) {
val context = LocalContext.current val context = LocalContext.current
val appState = remember { AppState(context) } val appState = remember { AppState(context) }
// Android 뒤로가기 = 바로 전 화면 // HOME에서 뒤로가기 → 앱 종료 확인
var showExitDialog by remember { mutableStateOf(false) }
BackHandler(enabled = appState.currentScreen == AppScreen.HOME) {
showExitDialog = true
}
if (showExitDialog) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showExitDialog = false },
title = { androidx.compose.material3.Text("Exit App") },
text = { androidx.compose.material3.Text("Are you sure you want to exit?") },
confirmButton = {
androidx.compose.material3.TextButton(onClick = { (context as? android.app.Activity)?.finish() }) {
androidx.compose.material3.Text("Exit", color = Color.Red)
}
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { showExitDialog = false }) {
androidx.compose.material3.Text("Cancel")
}
}
)
}
// 다른 화면에서 뒤로가기 = 바로 전 화면
BackHandler(enabled = appState.currentScreen != AppScreen.HOME BackHandler(enabled = appState.currentScreen != AppScreen.HOME
&& appState.currentScreen != AppScreen.PIN_ENTRY) { && appState.currentScreen != AppScreen.PIN_ENTRY) {
when (appState.currentScreen) { when (appState.currentScreen) {
@@ -273,22 +273,12 @@ fun PiezoMonitoringView(appState: AppState) {
Log.w("PiezoMonitor", "No center channels: center=0 total=$totalWallCount/6") Log.w("PiezoMonitor", "No center channels: center=0 total=$totalWallCount/6")
bleManager.debugLogger.warn("BV_SKIP scan=${com.example.medilightv2android.services.AdcCsvLogger.lastScanId} center=0 total=$totalWallCount/6") bleManager.debugLogger.warn("BV_SKIP scan=${com.example.medilightv2android.services.AdcCsvLogger.lastScanId} center=0 total=$totalWallCount/6")
com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, null) com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, null)
if (!isAutoMeasuring) {
handler.post { android.widget.Toast.makeText(context,
"측정 부족 (${centerWallCount}/4채널) — 센서 위치를 조절해주세요",
android.widget.Toast.LENGTH_SHORT).show() }
}
} }
} else { } else {
Log.d("PiezoMonitor", "No valid channels — volume unchanged") Log.d("PiezoMonitor", "No valid channels — volume unchanged")
bleManager.debugLogger.warn("BV_FAIL scan=${com.example.medilightv2android.services.AdcCsvLogger.lastScanId} no valid channels") bleManager.debugLogger.warn("BV_FAIL scan=${com.example.medilightv2android.services.AdcCsvLogger.lastScanId} no valid channels")
val failADC = channels.sortedBy { it.channel }.map { it.buffer } val failADC = channels.sortedBy { it.channel }.map { it.buffer }
com.example.medilightv2android.services.AdcCsvLogger.log(failADC, null) com.example.medilightv2android.services.AdcCsvLogger.log(failADC, null)
if (!isAutoMeasuring) {
handler.post { android.widget.Toast.makeText(context,
"방광이 감지되지 않았습니다. 센서 접촉을 확인해주세요.",
android.widget.Toast.LENGTH_SHORT).show() }
}
} }
val finalRawVol = rawVolumeMl val finalRawVol = rawVolumeMl
@@ -575,7 +565,7 @@ fun PiezoMonitoringView(appState: AppState) {
) { ) {
Icon(Icons.Default.FlashOn, contentDescription = null, tint = Color(0xFF4CAF50)) Icon(Icons.Default.FlashOn, contentDescription = null, tint = Color(0xFF4CAF50))
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text("빠른 저장", fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) Text("Quick Save", fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("$quickVol ml", color = MlSecondaryText, fontSize = 14.sp) Text("$quickVol ml", color = MlSecondaryText, fontSize = 14.sp)
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText) Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText)
@@ -707,7 +697,7 @@ fun PiezoMonitoringView(appState: AppState) {
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// Battery percentage // Battery percentage (초록 dot 제거)
if (!isDemoMode) { if (!isDemoMode) {
val batteryColor = when { val batteryColor = when {
batteryLvl >= 60 -> Color(0xFF4CAF50) batteryLvl >= 60 -> Color(0xFF4CAF50)
@@ -717,14 +707,6 @@ fun PiezoMonitoringView(appState: AppState) {
Text("$batteryLvl%", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = batteryColor) Text("$batteryLvl%", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = batteryColor)
} }
// Connection dot
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (isDemoMode || isConnected) Color(0xFF4CAF50) else Color.Red)
)
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
// Settings button // Settings button
@@ -780,65 +762,57 @@ fun PiezoMonitoringView(appState: AppState) {
val donutSize = if (appState.isDevMode) { val donutSize = if (appState.isDevMode) {
if (isCompact) 220.dp else 280.dp if (isCompact) 220.dp else 280.dp
} else { } else {
if (isCompact) 260.dp else 320.dp // 일반 모드: 더 크게 if (isCompact) 300.dp else 360.dp // 일반 모드: 화면 거의 가득
} }
// maxVolume 기준 비율 (0.0~1.0, 부드러운 연속값)
val maxVolumeForDonut = appState.piezoSettings.maxVolume.toDouble() val maxVolumeForDonut = appState.piezoSettings.maxVolume.toDouble()
val donutProgress = if (maxVolumeForDonut > 0 && maxVolumeMl > 0) val donutProgress = if (maxVolumeForDonut > 0 && maxVolumeMl > 0)
(maxVolumeMl / maxVolumeForDonut).coerceIn(0.0, 1.0) else 0.0 (maxVolumeMl / maxVolumeForDonut).coerceIn(0.0, 1.0) else 0.0
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
// 원 내부 흰색 톤 배경
if (!appState.isDevMode) {
Box(
modifier = Modifier
.size(donutSize - 50.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.5f))
)
}
DonutChartView( DonutChartView(
progress = donutProgress, progress = donutProgress,
urgency = urgency, urgency = urgency,
lineWidth = if (isCompact) 22.dp else 30.dp, lineWidth = if (appState.isDevMode) {
if (isCompact) 22.dp else 30.dp
} else {
if (isCompact) 26.dp else 34.dp
},
size = donutSize size = donutSize
) )
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
BladdyRiveView(urgency = urgency, size = if (isCompact) 80.dp else 110.dp) // Bladdy 크기: 일반 모드에서 더 크게
Spacer(modifier = Modifier.height(4.dp)) val bladdySize = if (appState.isDevMode) {
if (isAutoMeasuring && autoVolumeWindow.size < 5 && lastVolumeMl <= 0) { if (isCompact) 80.dp else 110.dp
// Auto 측정 대기 중 — trimmed mean 산출 전
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MlCardBackground)
.padding(horizontal = 14.dp, vertical = 6.dp)
) {
CircularProgressIndicator(
color = Color(0xFFFF9800),
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Measuring... (${autoVolumeWindow.size}/5)",
fontSize = 14.sp, fontWeight = FontWeight.SemiBold,
color = Color(0xFFFF9800))
}
} else { } else {
val volFontSize = if (appState.isDevMode) 18.sp else 22.sp if (isCompact) 120.dp else 150.dp // 원 지름의 ~1/2
Text(
if (maxVolumeMl > 0) "${"%.0f".format(maxVolumeMl)} ml" else "${appState.estimatedVolume} ml",
fontSize = volFontSize,
fontWeight = FontWeight.Bold,
color = urgency.accentColor,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MlCardBackground)
.shadow(4.dp, RoundedCornerShape(12.dp), spotColor = Color.Black.copy(alpha = 0.08f))
.padding(horizontal = 16.dp, vertical = 8.dp)
)
} }
BladdyRiveView(urgency = urgency, size = bladdySize)
Spacer(modifier = Modifier.height(4.dp))
// 볼륨 텍스트 — 배경 없이 깔끔하게
val volFontSize = if (appState.isDevMode) 18.sp else 24.sp
Text(
if (maxVolumeMl > 0) "${"%.0f".format(maxVolumeMl)} ml" else "${appState.estimatedVolume} ml",
fontSize = volFontSize,
fontWeight = FontWeight.Bold,
color = urgency.accentColor,
)
} }
} }
// Info cards (Fill/Level은 maxVolume 기준) // Info cards (Fill/Level은 maxVolume 기준)
val maxVol = appState.piezoSettings.maxVolume.toDouble() val maxVol = appState.piezoSettings.maxVolume.toDouble()
val fillPct = if (maxVol > 0 && lastVolumeMl > 0) (lastVolumeMl / maxVol * 100).coerceAtMost(100.0) else 0.0 // fill/level은 maxVolumeMl(세션 최대) 기준
val levelVal = if (maxVol > 0 && lastVolumeMl > 0) ((lastVolumeMl / maxVol) * BladderLevel.MAX_LEVEL).toInt().coerceIn(0, BladderLevel.MAX_LEVEL) else 0 val fillPct = if (maxVol > 0 && maxVolumeMl > 0) (maxVolumeMl / maxVol * 100).coerceAtMost(100.0) else 0.0
val levelVal = if (maxVol > 0 && maxVolumeMl > 0) ((maxVolumeMl / maxVol) * BladderLevel.MAX_LEVEL).toInt().coerceIn(0, BladderLevel.MAX_LEVEL) else 0
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
val displayVol = if (lastVolumeMl > 0) "%.0f".format(lastVolumeMl) else "—"
InfoCard("Volume", displayVol, "ml", urgency.accentColor, Modifier.weight(1f))
InfoCard("Fill", if (fillPct > 0) "%.0f".format(fillPct) else "—", "%", urgency.accentColor, Modifier.weight(1f)) InfoCard("Fill", if (fillPct > 0) "%.0f".format(fillPct) else "—", "%", urgency.accentColor, Modifier.weight(1f))
InfoCard("Level", if (levelVal > 0) "$levelVal/${BladderLevel.MAX_LEVEL}" else "—", "", urgency.accentColor, Modifier.weight(1f)) InfoCard("Level", if (levelVal > 0) "$levelVal/${BladderLevel.MAX_LEVEL}" else "—", "", urgency.accentColor, Modifier.weight(1f))
} }
@@ -167,16 +167,41 @@ fun PlacementGuideView(appState: AppState) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp), .padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Step indicator + mode badge (이미지 위에 배치)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 4.dp)
) {
Text(
text = when (placementPhase) {
PlacementPhase.VERTICAL -> "Step 1/3"
PlacementPhase.LATERAL -> "Step 2/3"
PlacementPhase.GREEN -> "Step 3/3"
},
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText
)
if (isSweepMode) {
Text(
"SWEEP",
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier
.background(Color(0xFF7C3AED), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 1.dp)
)
}
}
// ── 하복부 Canvas 실루엣 + 센서 + 방향 가이드 ── // ── 하복부 Canvas 실루엣 + 센서 + 방향 가이드 ──
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(500.dp) .weight(1f),
.padding(top = 8.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// 하복부 실루엣 (Canvas — CKLaw body silhouette) // 하복부 실루엣 (Canvas — CKLaw body silhouette)
@@ -278,10 +303,10 @@ fun PlacementGuideView(appState: AppState) {
// ── 방향 화살표 (방광 타원 바깥) ── // ── 방향 화살표 (방광 타원 바깥) ──
if (!greenZone) { if (!greenZone) {
val arrowColor = Color(0xFFFF5722) val arrowColor = Color(0xFFFF5722)
val arrowLen = 50f val arrowLen = 70f
val arrowHead = 20f val arrowHead = 28f
val sw = 7f val sw = 9f
val gap = 12f val gap = 14f
when { when {
directionIcon.contains("up") -> { directionIcon.contains("up") -> {
@@ -336,40 +361,41 @@ fun PlacementGuideView(appState: AppState) {
alpha = if (isGreenZone) 0.7f else 0.95f alpha = if (isGreenZone) 0.7f else 0.95f
) )
// Score + 감지수 + R-fit (우측 상단) // Score + 감지수 + R-fit (개발자 모드에서만)
Column( if (appState.isDevMode) {
modifier = Modifier Column(
.align(Alignment.TopEnd) modifier = Modifier
.padding(4.dp), .align(Alignment.TopEnd)
horizontalAlignment = Alignment.End .padding(4.dp),
) { horizontalAlignment = Alignment.End
Text("$detectedCount/6", fontSize = 16.sp, fontWeight = FontWeight.Bold, ) {
color = if (detectedCount >= 4) Color(0xFF4CAF50) else Color(0xFFFF9800)) Text("$detectedCount/6", fontSize = 16.sp, fontWeight = FontWeight.Bold,
if (placementScore > 0) { color = if (detectedCount >= 4) Color(0xFF4CAF50) else Color(0xFFFF9800))
Text("$placementScore%", fontSize = 13.sp, fontWeight = FontWeight.Bold, if (placementScore > 0) {
color = when { Text("$placementScore%", fontSize = 13.sp, fontWeight = FontWeight.Bold,
placementScore >= 80 -> Color(0xFF4CAF50) color = when {
placementScore >= 50 -> Color(0xFFFF9800) placementScore >= 80 -> Color(0xFF4CAF50)
else -> Color.Red placementScore >= 50 -> Color(0xFFFF9800)
}) else -> Color.Red
} })
// SWEEP 모드: R-fit + argmax 표시
if (isSweepMode && currentRMm != null) {
val rColor = when {
bestRMm > 0 && (bestRMm - currentRMm!!) < 1.5 -> Color(0xFF4CAF50) // PEAK
currentRMm!! > (bestRMm * 0.8) -> Color(0xFF7C3AED) // RISING
else -> Color(0xFFFF9800) // FALLING/NOISE
} }
Text("R=${"%.0f".format(currentRMm)}mm", fontSize = 12.sp, if (isSweepMode && currentRMm != null) {
fontWeight = FontWeight.Bold, color = rColor) val rColor = when {
if (bestRMm > 0) { bestRMm > 0 && (bestRMm - currentRMm!!) < 1.5 -> Color(0xFF4CAF50)
Text("best=${"%.0f".format(bestRMm)}", fontSize = 10.sp, currentRMm!! > (bestRMm * 0.8) -> Color(0xFF7C3AED)
color = Color(0xFF4CAF50).copy(alpha = 0.7f)) else -> Color(0xFFFF9800)
} }
sagittalArgMaxCh?.let { arg -> Text("R=${"%.0f".format(currentRMm)}mm", fontSize = 12.sp,
Text("argmax=CH$arg", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = rColor)
fontWeight = FontWeight.Bold, if (bestRMm > 0) {
color = if (arg == 0) Color(0xFF4CAF50) else Color(0xFFFF9800)) Text("best=${"%.0f".format(bestRMm)}", fontSize = 10.sp,
color = Color(0xFF4CAF50).copy(alpha = 0.7f))
}
sagittalArgMaxCh?.let { arg ->
Text("argmax=CH$arg", fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = if (arg == 0) Color(0xFF4CAF50) else Color(0xFFFF9800))
}
} }
} }
} }
@@ -414,33 +440,6 @@ fun PlacementGuideView(appState: AppState) {
} }
} }
Spacer(modifier = Modifier.height(4.dp))
// Step indicator + mode badge
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = when (placementPhase) {
PlacementPhase.VERTICAL -> "Step 1/3"
PlacementPhase.LATERAL -> "Step 2/3"
PlacementPhase.GREEN -> "Step 3/3"
},
fontSize = 11.sp, color = MlSecondaryText
)
if (isSweepMode) {
Text(
"SWEEP",
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier
.background(Color(0xFF7C3AED), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 1.dp)
)
}
}
// Per-channel echo graphs (개발자 모드에서만 표시) // Per-channel echo graphs (개발자 모드에서만 표시)
if (appState.isDevMode && lastChannelData.isNotEmpty()) { if (appState.isDevMode && lastChannelData.isNotEmpty()) {