feat: 측정 가로모드 대응 + Ant Offset + Scan Log + 다중 sanity check
## 측정 화면 (PiezoMonitoringView)
- BoxWithConstraints + donutFitSize: 가로/작은 화면에서도 donut+Bladdy+buttons
모두 들어오도록 height-aware sizing (cap 120~360dp)
- nonDevSpacing: available height 기반 단계 (18/22/28/36) — 큰 화면 답답함 해소
- center 4 중 1개라도 missing → 즉시 realign prompt (이전: AutoMeasure 5회 누적)
- 6채널 중 3개 이상 missing 시 위치 이탈 알림 (모드 무관)
- 다이얼로그 문구: "Position Lost or Insufficient Gel"
- V41 결과 후처리 — walls를 PiezoEchoAnalyzer 기준으로 강제 (UI 차트와 일관)
· ant = analyzer.result.ant
· post = analyzer.result.lowEnd + 3
- CH3 secondary echo 잡힘 / CH4·CH5 lateral ant too-deep 문제 해결
- Bladdy 3-tap → Developer Mode toggle (측정 화면에서도)
## Placement 가이드 (PlacementGuideView)
- 가로 모드 대응: bodyW = min(w, h*0.7f) — 태블릿 가로에서 portrait 비율 유지
- Sensor 이미지를 Canvas drawImage 로 이동 — bladder oval 좌표계와 정확히 정렬
- Start Alignment 버튼: enabled = true 명시
## Settings overlay
- "Ant Offset" 숫자 입력 필드 (0~20mm, dev mode 전용)
· BV 계산 시 ant 를 입력값만큼 probe 쪽으로 당김 (chord 증가 → BV 증가)
· mm→samples 변환은 PiezoHW.distancePerSample
· V41 SphereFit BV 는 offset≠0 일 때 우회 → 6ch estimate
- "Version" row 추가 (BuildConfig.VERSION_NAME)
- Save 버튼: 실제 UserStorage 영구 저장 + state 재전파
· AppState.savePiezoSettingsNow() 추가
- 전체 overlay 에 verticalScroll + Card maxHeight 90% — dev 컨텐츠 길어도 Save 도달
## DPS 동기화 (긴급 수정)
설정의 DPS 변경이 V41Detector(Geometry/AnatomicalGate)에 반영 안 되던 문제:
- WdConfig.DPS_DEFAULT: const val → @Volatile @JvmField var
- PiezoHW.distancePerSample setter 가 WdConfig.DPS_DEFAULT 자동 동기화
- 시작 시점 1.968.also { WdConfig.DPS_DEFAULT = it } 로 두 값 일치
## 버전
- versionName: VBTAND0101 → 2026005-101
- versionCode: 2 → 3
## 신규 모듈
- services/ScanLogStore.kt — dev mode 진단 로그 (마지막 Single Scan summary)
· WALLS CSV (ch,ant,post,urine), DATA CSV (raw ADC), antOffsetMm
- ui/components/ScanLogDialog.kt — 클립보드 복사 다이얼로그
· xbench (CharlesKWONsLaw) MbbLogDialog 패턴 차용
· SelectionContainer + monospace + Copy/Clear/Close
- 측정 화면 dev panel 에 "Scan Log (N)" 버튼 추가
## 데이터 모델
- PiezoSettings.antOffsetMm: Int = 0 필드 추가
## 단위 테스트
- V41DetectorCH4Test.kt — 2026-05-06 floating-signal 캡쳐 6채널 raw 를
V41Detector.detect 에 통과시켜 결과 출력 (CH4 가짜 wall 진단용)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -127,6 +127,15 @@ class AppState(private val context: Context) {
|
||||
currentScreen = AppScreen.PLACEMENT_GUIDE
|
||||
}
|
||||
|
||||
/** Persist piezoSettings (called from in-session settings save). */
|
||||
fun savePiezoSettingsNow() {
|
||||
// Re-assign to trigger the mutableStateOf delegate so any composables
|
||||
// observing piezoSettings recompose with the latest value, then persist
|
||||
// to disk so the change survives process death.
|
||||
piezoSettings = piezoSettings.copy()
|
||||
storage.piezoSettings = piezoSettings
|
||||
}
|
||||
|
||||
fun placementComplete() {
|
||||
bladderLevel = BladderLevel(currentLevel = 0, catheterizationThreshold = bladderLevel.catheterizationThreshold)
|
||||
storage.bladderLevel = 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.example.medilightv2android.managers
|
||||
|
||||
import com.example.medilightv2android.walldetect.core.WdConfig
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
@@ -95,8 +96,20 @@ object PiezoHW {
|
||||
val degree: DoubleArray get() = centerCh.map { degreeAll[it] }.toDoubleArray()
|
||||
val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray()
|
||||
|
||||
/** Acoustic calibration (런타임 조절 가능) */
|
||||
@Volatile var distancePerSample: Double = 1.968 // mm/sample
|
||||
/** Acoustic calibration (런타임 조절 가능).
|
||||
* Setter는 walldetect.core.WdConfig.DPS_DEFAULT 도 함께 동기화한다.
|
||||
* V41Detector / Geometry / AnatomicalGate 가 WdConfig 측 상수를 사용하기
|
||||
* 때문에 한 곳만 바꾸면 BV 두 경로 (6ch estimate vs V41 dispatch / gate) 가
|
||||
* 서로 다른 dps 로 계산되어 측정이 어긋난다. */
|
||||
// `.also` 로 초기값을 walldetect.core.WdConfig 측에도 동시에 반영 — 시작
|
||||
// 시점부터 두 경로의 dps 가 일치한다.
|
||||
@Volatile private var _distancePerSample: Double = 1.968.also { WdConfig.DPS_DEFAULT = it }
|
||||
var distancePerSample: Double
|
||||
get() = _distancePerSample
|
||||
set(value) {
|
||||
_distancePerSample = value
|
||||
WdConfig.DPS_DEFAULT = value
|
||||
}
|
||||
const val delayOffsetMm: Double = 6.85
|
||||
|
||||
/** Volume model */
|
||||
|
||||
@@ -14,5 +14,10 @@ enum class SensorPosition(val displayName: String, val description: String) {
|
||||
data class PiezoSettings(
|
||||
var posture: Posture = Posture.SITTING,
|
||||
var sensorPosition: SensorPosition = SensorPosition.LOWER_ABDOMEN,
|
||||
var maxVolume: Int = 500
|
||||
var maxVolume: Int = 500,
|
||||
// BV calculation tweak — anterior-wall pull-in magnitude in millimetres.
|
||||
// The stored value is always non-negative; BV calculation applies it as a
|
||||
// negative (probe-ward) shift on the ant index. Default 0 keeps legacy
|
||||
// behaviour.
|
||||
var antOffsetMm: Int = 0,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.example.medilightv2android.services
|
||||
|
||||
import com.example.medilightv2android.ble.PiezoChannelData
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Holds the **last** Single Scan summary line so it can be inspected and
|
||||
* copied to the clipboard from the developer-mode log dialog. Each new
|
||||
* Single Scan replaces the previous entry. Format and clipboard interface
|
||||
* mirror the xbench WallCompareViewModel mbb log so the same downstream
|
||||
* parsers / spreadsheets work unchanged.
|
||||
*/
|
||||
object ScanLogStore {
|
||||
private val tsFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
private val _scanLog = MutableStateFlow<List<String>>(emptyList())
|
||||
val scanLog: StateFlow<List<String>> = _scanLog.asStateFlow()
|
||||
|
||||
fun clear() {
|
||||
_scanLog.value = emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the stored line with the latest Single Scan summary. If the
|
||||
* latest successful measurement produced anterior/posterior wall indices,
|
||||
* a CSV block is appended so the walls can be pasted into Excel / numpy.
|
||||
* `successfulWalls[ch] = (ant, post)` or `null` if that channel failed.
|
||||
*/
|
||||
fun appendScanSummary(
|
||||
attemptCount: Int,
|
||||
spotVolumes: List<Double>,
|
||||
trimmedMeanMl: Double?,
|
||||
successfulWalls: List<Pair<Int, Int>?>? = null,
|
||||
successfulChannels: List<PiezoChannelData>? = null,
|
||||
antOffsetMm: Int = 0,
|
||||
) {
|
||||
val ts = tsFmt.format(System.currentTimeMillis())
|
||||
val sb = StringBuilder()
|
||||
sb.append("[$ts] SUMMARY attempts=$attemptCount results=${spotVolumes.size}")
|
||||
if (trimmedMeanMl != null) {
|
||||
sb.append(" trimmedMean=${"%.1f".format(trimmedMeanMl)}ml")
|
||||
if (spotVolumes.isNotEmpty()) {
|
||||
sb.append(" values=[${spotVolumes.joinToString(", ") { "%.1f".format(it) }}]")
|
||||
}
|
||||
} else {
|
||||
sb.append(" ✗ insufficient results")
|
||||
}
|
||||
if (antOffsetMm != 0) {
|
||||
sb.append(" antOffset=-${antOffsetMm}mm")
|
||||
}
|
||||
if (successfulWalls != null) {
|
||||
sb.append("\n WALLS (CSV — ch,ant,post,urine):")
|
||||
successfulWalls.forEachIndexed { ch, w ->
|
||||
if (w != null) {
|
||||
sb.append("\n CH$ch,${w.first},${w.second},${w.second - w.first}")
|
||||
} else {
|
||||
sb.append("\n CH$ch,,,")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (successfulChannels != null) {
|
||||
sb.append("\n DATA (CSV — one row per channel, comma-separated samples):")
|
||||
successfulChannels.sortedBy { it.channel }.forEach { ch ->
|
||||
val s = ch.buffer
|
||||
val csv = if (s.isEmpty()) "—"
|
||||
else s.joinToString(",") { (it.toInt() and 0xFFFF).toString() }
|
||||
sb.append("\n CH${ch.channel}[${s.size}]: $csv")
|
||||
}
|
||||
}
|
||||
_scanLog.value = listOf(sb.toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Scan-log diagnostic dialog. Format and interface mirror the xbench
|
||||
* MbbLogDialog (CharlesKWONsLaw / wallcompare) so the same downstream
|
||||
* clipboard / spreadsheet workflow applies: each entry is one Single Scan
|
||||
* measurement, newest at the bottom, monospace, long-press to select spans
|
||||
* across entries. Copy puts the full log on the system clipboard.
|
||||
*/
|
||||
package com.example.medilightv2android.ui.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
@Composable
|
||||
fun ScanLogDialog(
|
||||
log: List<String>,
|
||||
onClear: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(log.size) {
|
||||
if (log.isNotEmpty()) listState.scrollToItem(log.size - 1)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF182338), RoundedCornerShape(12.dp))
|
||||
.border(1.dp, Color(0xFF2D394F), RoundedCornerShape(12.dp))
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.0001f)) { }
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth(0.7f)) {
|
||||
Text(
|
||||
text = "scan diagnostic log",
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "${log.size} entries (newest at bottom)",
|
||||
color = Color.White.copy(0.5f),
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(Color(0xFF0F1F3D), RoundedCornerShape(6.dp))
|
||||
.border(1.dp, Color(0xFF1F2C44), RoundedCornerShape(6.dp))
|
||||
.padding(8.dp),
|
||||
) {
|
||||
if (log.isEmpty()) {
|
||||
Text(
|
||||
text = "no scan attempts yet — press Single Scan",
|
||||
color = Color.White.copy(0.4f),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
} else {
|
||||
SelectionContainer {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(log) { entry ->
|
||||
val isFail = entry.contains(" ✗ ")
|
||||
Text(
|
||||
text = entry,
|
||||
color = if (isFail) Color(0xFFA6192E)
|
||||
else Color(0xFFD9D9D6),
|
||||
fontSize = 10.sp,
|
||||
lineHeight = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val text = log.joinToString(separator = "\n────\n")
|
||||
copyToClipboard(ctx, text)
|
||||
Toast.makeText(
|
||||
ctx,
|
||||
"Copied ${log.size} entries to clipboard",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = Color(0xFF00A7B5),
|
||||
),
|
||||
enabled = log.isNotEmpty(),
|
||||
) { Text("Copy") }
|
||||
OutlinedButton(
|
||||
onClick = onClear,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = Color.White.copy(0.7f),
|
||||
),
|
||||
) { Text("Clear") }
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1.2f),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073CF)),
|
||||
) { Text("Close") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyToClipboard(ctx: Context, text: String) {
|
||||
val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
cm?.setPrimaryClip(ClipData.newPlainText("VesiScan scan log", text))
|
||||
}
|
||||
+267
-38
@@ -11,6 +11,7 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -49,10 +50,12 @@ import com.example.medilightv2android.managers.PiezoHW
|
||||
import com.example.medilightv2android.managers.estimateBladderVolume6ch
|
||||
import com.example.medilightv2android.models.BladderLevel
|
||||
import com.example.medilightv2android.models.UrgencyLevel
|
||||
import com.example.medilightv2android.services.ScanLogStore
|
||||
import com.example.medilightv2android.ui.components.BladdyRiveView
|
||||
import com.example.medilightv2android.ui.components.BleDisconnectionBanner
|
||||
import com.example.medilightv2android.ui.components.BleReconnectionState
|
||||
import com.example.medilightv2android.ui.components.DonutChartView
|
||||
import com.example.medilightv2android.ui.components.ScanLogDialog
|
||||
import com.example.medilightv2android.R
|
||||
import com.example.medilightv2android.ui.theme.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -75,6 +78,11 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) }
|
||||
var isAutoMeasuring by remember { mutableStateOf(false) }
|
||||
var showDevPanels by remember { mutableStateOf(false) }
|
||||
var showScanLogDialog by remember { mutableStateOf(false) }
|
||||
val scanLogState by ScanLogStore.scanLog.collectAsState()
|
||||
// Triple-tap Bladdy to toggle Developer Mode (parity with HomeView)
|
||||
var devTapCount by remember { mutableIntStateOf(0) }
|
||||
var devLastTapTime by remember { mutableLongStateOf(0L) }
|
||||
var isPlacementTesting by remember { mutableStateOf(false) }
|
||||
var measureRetryCount by remember { mutableIntStateOf(0) }
|
||||
val maxMeasureRetries = 2
|
||||
@@ -85,12 +93,21 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
var lastMaxUpdateMs by remember { mutableStateOf(0L) }
|
||||
var isSpotInProgress by remember { mutableStateOf(false) }
|
||||
val spotVolumes = remember { mutableListOf<Double>() }
|
||||
// Last successful (ant, post) wall pair per channel captured during Single Scan
|
||||
var spotSuccessfulWalls by remember { mutableStateOf<List<Pair<Int, Int>?>?>(null) }
|
||||
// Raw ADC channels paired with the above walls — same measurement snapshot
|
||||
var spotSuccessfulChannels by remember { mutableStateOf<List<PiezoChannelData>?>(null) }
|
||||
val measureDoneSignal = remember { mutableStateOf(kotlinx.coroutines.CompletableDeferred<Unit>()) }
|
||||
val lastMultiChannelResult = remember { mutableStateListOf<PiezoChannelData>() }
|
||||
var lastAllWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) }
|
||||
val analyzer = remember { PiezoEchoAnalyzer.shared }
|
||||
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp
|
||||
val screenHeight = LocalConfiguration.current.screenHeightDp
|
||||
// donut/Bladdy 같은 정사각 sizing은 portrait/landscape 양쪽에서 화면 밖으로 나가지
|
||||
// 않도록 min(width, height) 를 기준 차원으로 쓴다. padding/font 등 가로 폭 기반
|
||||
// sizing은 screenWidth 를 그대로 사용.
|
||||
val refDim = kotlin.math.min(screenWidth, screenHeight)
|
||||
val isCompact = screenWidth < 400
|
||||
// 태블릿(600dp+) 글자 스케일: 폰=1.0, 태블릿=~1.6
|
||||
val fontScale = (screenWidth / 360f).coerceIn(1.0f, 1.8f)
|
||||
@@ -203,11 +220,23 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
sweepSeq = 0, probe = probe, adc = adcMatrix
|
||||
)
|
||||
val result = v41.detect(sweepInput)
|
||||
for (ch in result.perChannel) {
|
||||
val gated = ch.v41Diag?.gated == true
|
||||
if (gated && ch.antIdx != null && ch.postIdx != null && ch.ch < 6
|
||||
&& (ch.postIdx - ch.antIdx) >= 3) {
|
||||
allWalls[ch.ch] = Pair(ch.antIdx, ch.postIdx)
|
||||
// 사용자 의도(2026-05-12): walls를 UI 차트와 정확히 일관 유지 — V41 ant/post
|
||||
// 무시하고 PiezoEchoAnalyzer 기준 사용. lateral 채널(CH4/5)에서 V41이 ant를
|
||||
// 뒤로 잡는 문제도 동시에 해결.
|
||||
// • ant = analyzer.result.ant (차트의 녹색 ant marker)
|
||||
// • post = analyzer.result.lowEnd + 3 (녹색 lumen 끝 + 3칸)
|
||||
val analyzerForLumen = analyzer.analyzeMultiChannel(validChannels)
|
||||
for (ch in 0..5) {
|
||||
val v41Ch = result.perChannel.firstOrNull { it.ch == ch }
|
||||
val gated = v41Ch?.v41Diag?.gated == true
|
||||
val analyzerCh = analyzerForLumen.channels
|
||||
.firstOrNull { it.channel == ch }
|
||||
val analyzerAnt = analyzerCh?.result?.ant
|
||||
val analyzerLowEnd = analyzerCh?.result?.lowEnd
|
||||
val effectivePost = analyzerLowEnd?.let { it + 3 }
|
||||
if (gated && analyzerAnt != null && effectivePost != null
|
||||
&& (effectivePost - analyzerAnt) >= 3) {
|
||||
allWalls[ch] = Pair(analyzerAnt, effectivePost)
|
||||
}
|
||||
}
|
||||
methodCBvMl = result.summary.bvDispatch?.bvMl?.toDouble()
|
||||
@@ -262,9 +291,31 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
val rawADC = channels.sortedBy { it.channel }.map { it.buffer }
|
||||
|
||||
val bvMethodSetting = com.example.medilightv2android.managers.GreenZoneConstants.bvMethod
|
||||
// ── Ant-offset adjustment for BV calculation ───────────────────────────────
|
||||
// The user-facing setting is a non-negative magnitude in mm. BV calculation
|
||||
// subtracts it from each channel's anterior wall index (probe-ward shift).
|
||||
// Default 0 keeps legacy behaviour. When non-zero we skip the V41 SphereFit
|
||||
// BV (it can't be re-fitted from indices alone) and route to the 6-channel
|
||||
// estimator with the adjusted walls so the user sees a consistent BV.
|
||||
val antOffsetMm = appState.piezoSettings.antOffsetMm
|
||||
val antOffsetSamples =
|
||||
if (antOffsetMm != 0)
|
||||
(antOffsetMm.toDouble() /
|
||||
com.example.medilightv2android.managers.PiezoHW.distancePerSample).toInt()
|
||||
else 0
|
||||
val effectiveAllWalls: List<Pair<Int, Int>?> =
|
||||
if (antOffsetSamples == 0) allWalls
|
||||
else allWalls.map { w ->
|
||||
if (w == null) null
|
||||
else Pair(maxOf(0, w.first - antOffsetSamples), w.second)
|
||||
}
|
||||
val effectiveCenterWalls = (0..3).count { effectiveAllWalls[it] != null }
|
||||
|
||||
val useV41Bv = method == com.example.medilightv2android.managers.DetectionMethod.METHOD_C
|
||||
&& bvMethodSetting == com.example.medilightv2android.managers.BvMethod.V41
|
||||
&& methodCBvMl != null
|
||||
&& antOffsetSamples == 0
|
||||
&& effectiveCenterWalls >= 4 // center 4 모두 필요
|
||||
|
||||
if (useV41Bv) {
|
||||
rawVolumeMl = methodCBvMl!!
|
||||
@@ -273,8 +324,19 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
(0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 })
|
||||
logService.logMeasurement(methodCBvMl, channelLogs, rawADC)
|
||||
com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, methodCBvMl)
|
||||
} else if (centerWallCount >= 1) {
|
||||
val bvResult = estimateBladderVolume6ch(allWalls)
|
||||
} else if (effectiveCenterWalls >= 4) {
|
||||
// 검증 로그 — ant offset 비활성(0)이 아닐 때 chord 변화 출력
|
||||
if (antOffsetSamples != 0) {
|
||||
val antOrig = (0..3).map { allWalls[it]?.first ?: -1 }
|
||||
val antAdj = (0..3).map { effectiveAllWalls[it]?.first ?: -1 }
|
||||
val chordOrig = (0..3).map { allWalls[it]?.let { w -> w.second - w.first } ?: -1 }
|
||||
val chordAdj = (0..3).map { effectiveAllWalls[it]?.let { w -> w.second - w.first } ?: -1 }
|
||||
Log.d("PiezoMonitor", "ANT_OFFSET verify: mm=$antOffsetMm samples=$antOffsetSamples " +
|
||||
"ant_orig=$antOrig ant_adj=$antAdj " +
|
||||
"chord_orig=$chordOrig chord_adj=$chordAdj " +
|
||||
"chord_delta(samples)=${chordAdj.mapIndexed { i, c -> if (c >= 0 && chordOrig[i] >= 0) c - chordOrig[i] else 0 }}")
|
||||
}
|
||||
val bvResult = estimateBladderVolume6ch(effectiveAllWalls)
|
||||
if (bvResult != null) {
|
||||
rawVolumeMl = bvResult.volumeMl
|
||||
rawLrRatio = bvResult.lrRatio
|
||||
@@ -335,11 +397,13 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto 연속 실패 → UI에서 처리
|
||||
// 채널 2개 이상 미수신 또는 BV 계산 불가
|
||||
// 측정 모드: 6채널 중 3개 이상 missing → 위치 이탈로 즉시 판정.
|
||||
// 'allChannelsReceived' 가드로 BLE 거리/패킷 문제와 구분한다.
|
||||
val centerWallsFinal = (0..3).count { allWalls[it] != null }
|
||||
val autoFailed = isAutoMeasuring && (centerWallsFinal < 2 || finalRawVol == null || finalRawVol <= 0)
|
||||
val totalWallsFinal = allWalls.count { it != null }
|
||||
val missingChannels = 6 - totalWallsFinal
|
||||
val autoSucceeded = isAutoMeasuring && finalRawVol != null && finalRawVol > 0 && allChannelsReceived
|
||||
val placementLost = missingChannels >= 3 && allChannelsReceived
|
||||
|
||||
// displayMaxVolumeMl 5초 간격 갱신
|
||||
val nowMs = System.currentTimeMillis()
|
||||
@@ -354,16 +418,31 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL)
|
||||
appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level)
|
||||
}
|
||||
// Auto 연속 실패 카운트
|
||||
if (autoSucceeded) autoFailCount = 0
|
||||
if (autoFailed) {
|
||||
autoFailCount++
|
||||
if (autoFailCount >= 5) {
|
||||
|
||||
// 6채널 중 3개 이상 안 잡힘 → 즉시 realign 알림 (모드 무관).
|
||||
if (placementLost && !showRealignPrompt) {
|
||||
if (isAutoMeasuring) {
|
||||
isAutoMeasuring = false
|
||||
autoVolumeWindow.clear()
|
||||
autoFailCount = 0
|
||||
showRealignPrompt = true
|
||||
}
|
||||
if (isSpotInProgress) {
|
||||
// SingleScan 진행 중이면 spot 종료 (남은 시도 중단)
|
||||
isSpotInProgress = false
|
||||
spotVolumes.clear()
|
||||
}
|
||||
Log.w("PiezoMonitor", "POSITION_LOST — missing=$missingChannels/6 (walls=$totalWallsFinal/6, center=$centerWallsFinal/4), prompting realign")
|
||||
showRealignPrompt = true
|
||||
}
|
||||
// Capture last walls with ≥1 detected (ant, post) pair — even when
|
||||
// BV calculation failed (e.g. center channels missing). Used by
|
||||
// the Single Scan dev log so the latest partial-detection sticks.
|
||||
// Raw ADC channels are captured from the same snapshot so the CSV
|
||||
// dump in the log aligns with the WALLS block.
|
||||
if (isSpotInProgress && wallsCopy.any { it != null }) {
|
||||
spotSuccessfulWalls = wallsCopy
|
||||
spotSuccessfulChannels = channels.toList()
|
||||
}
|
||||
isMeasuring = false
|
||||
measureRetryCount = 0
|
||||
@@ -846,17 +925,34 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
// BoxWithConstraints — 사용 가능한 height 를 측정해 donut/Bladdy 크기를 동적으로
|
||||
// 결정한다. 가로 모드 / 작은 화면에서도 progress + 다른 컨텐츠가 모두 들어오도록.
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val availableH = this.maxHeight
|
||||
// 비-donut 컨텐츠(status badge + 정보 카드 + 버튼 + spacing + padding) 예약치.
|
||||
// 이 값 빼고 남은 공간이 donut.
|
||||
val nonDonutBudget = if (appState.isDevMode) 280.dp else 360.dp
|
||||
// donut max를 키워서 큰 화면(태블릿 portrait, 가로 태블릿)에서도 답답하지 않게.
|
||||
val donutFitSize = (availableH - nonDonutBudget).coerceIn(120.dp, 360.dp)
|
||||
// spacing 도 화면 높이에 비례 — 큰 화면에서 컨텐츠 사이 여백 넉넉히.
|
||||
val nonDevSpacing = when {
|
||||
availableH > 800.dp -> 36.dp
|
||||
availableH > 600.dp -> 28.dp
|
||||
isCompact -> 18.dp
|
||||
else -> 22.dp
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
// dev 모드만 scroll — 일반 사용자에겐 fit-to-screen 고정 레이아웃
|
||||
.then(if (appState.isDevMode) Modifier.verticalScroll(scrollState) else Modifier)
|
||||
.padding(horizontal = hPad)
|
||||
.padding(top = if (isCompact) 8.dp else 16.dp, bottom = if (isCompact) 16.dp else 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = if (appState.isDevMode)
|
||||
Arrangement.spacedBy(if (isCompact) 12.dp else 20.dp)
|
||||
else
|
||||
Arrangement.spacedBy((screenWidth * 0.08f).dp.coerceIn(20.dp, 48.dp))
|
||||
Arrangement.spacedBy(nonDevSpacing)
|
||||
) {
|
||||
|
||||
// Status badge (항상 urgency 메시지)
|
||||
@@ -873,15 +969,26 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
// Donut chart with Bladdy — 화면 비율 기반
|
||||
val screenWidthDp = screenWidth.dp
|
||||
val donutRatio = if (appState.isDevMode) 0.65f else 0.75f
|
||||
val donutSize = screenWidthDp * donutRatio
|
||||
val lineWidthRatio = if (appState.isDevMode) 0.07f else 0.08f
|
||||
val donutLineWidth = donutSize * lineWidthRatio
|
||||
val bladdyRatio = if (appState.isDevMode) 0.30f else 0.55f
|
||||
val bladdySize = donutSize * bladdyRatio
|
||||
val volFontSize = if (appState.isDevMode) 18.sp else (screenWidth * 0.06f).sp
|
||||
// Donut + Bladdy sizing — BoxWithConstraints 의 availableH 기반.
|
||||
// - dev 모드: isCompact 기반 고정 dp (scroll 가능)
|
||||
// - non-dev: donutFitSize 사용 → 화면 높이에 맞춰 자동 축소 (120~240dp cap)
|
||||
val baseDonutSize = if (appState.isDevMode) {
|
||||
if (isCompact) 192.dp else 244.dp
|
||||
} else {
|
||||
donutFitSize
|
||||
}
|
||||
val donutSize = baseDonutSize * 0.95f
|
||||
val donutLineWidth = if (appState.isDevMode) {
|
||||
if (isCompact) 22.dp else 30.dp
|
||||
} else {
|
||||
if (isCompact) 26.dp else 34.dp
|
||||
}
|
||||
val bladdySize = if (appState.isDevMode) {
|
||||
if (isCompact) 80.dp else 110.dp
|
||||
} else {
|
||||
donutSize * 0.85f // donut 내부에 항상 들어가는 크기
|
||||
}
|
||||
val volFontSize = if (appState.isDevMode) 18.sp else 24.sp
|
||||
|
||||
val maxVolumeForDonut = appState.piezoSettings.maxVolume.toDouble()
|
||||
val donutProgress = if (maxVolumeForDonut > 0 && maxVolumeMl > 0)
|
||||
@@ -908,7 +1015,31 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.matchParentSize().offset(y = -(donutSize * 0.03f))
|
||||
) {
|
||||
BladdyRiveView(urgency = urgency, size = bladdySize)
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
BladdyRiveView(urgency = urgency, size = bladdySize)
|
||||
// Transparent overlay — Rive AndroidView swallows touches,
|
||||
// so we layer a clickable Box on top to catch the 3-tap
|
||||
// dev-mode gesture (same trick HomeView uses).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(bladdySize)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - devLastTapTime > 1500) devTapCount = 0
|
||||
devLastTapTime = now
|
||||
devTapCount++
|
||||
if (devTapCount >= 3) {
|
||||
devTapCount = 0
|
||||
appState.isDevMode = !appState.isDevMode
|
||||
val msg = if (appState.isDevMode) "Developer mode ON" else "Developer mode OFF"
|
||||
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
if (displayMaxVolumeMl > 0) "${"%.0f".format(displayMaxVolumeMl)} mL" else "${appState.estimatedVolume} mL",
|
||||
@@ -996,6 +1127,8 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
isSpotInProgress = true
|
||||
spotCooldown = true
|
||||
spotVolumes.clear()
|
||||
spotSuccessfulWalls = null
|
||||
spotSuccessfulChannels = null
|
||||
spotScope.launch {
|
||||
val maxAttempts = 8
|
||||
val targetCount = 5
|
||||
@@ -1007,17 +1140,28 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
kotlinx.coroutines.delay(100) // throttle(800ms)이 간격 보장
|
||||
}
|
||||
Log.d("PiezoMonitor", "SPOT attempts=$attempts results=${spotVolumes.size}")
|
||||
if (spotVolumes.size >= 3) {
|
||||
val result = trimmedMean(spotVolumes.toList())
|
||||
lastVolumeMl = result
|
||||
maxVolumeMl = maxOf(maxVolumeMl, result)
|
||||
val finalSpotVolumes = spotVolumes.toList()
|
||||
val trimmedResult = if (finalSpotVolumes.size >= 3) trimmedMean(finalSpotVolumes) else null
|
||||
if (trimmedResult != null) {
|
||||
lastVolumeMl = trimmedResult
|
||||
maxVolumeMl = maxOf(maxVolumeMl, trimmedResult)
|
||||
displayMaxVolumeMl = maxVolumeMl // Spot은 즉시 갱신
|
||||
Log.d("PiezoMonitor", "SPOT trimmedMean=${"%.1f".format(result)}ml from ${spotVolumes.map { "%.0f".format(it) }}")
|
||||
Log.d("PiezoMonitor", "SPOT trimmedMean=${"%.1f".format(trimmedResult)}ml from ${finalSpotVolumes.map { "%.0f".format(it) }}")
|
||||
val maxVol = appState.piezoSettings.maxVolume.toDouble()
|
||||
val pct = if (maxVol > 0) (result / maxVol).coerceAtMost(1.0) else 0.0
|
||||
val pct = if (maxVol > 0) (trimmedResult / maxVol).coerceAtMost(1.0) else 0.0
|
||||
val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL)
|
||||
appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level)
|
||||
}
|
||||
if (appState.isDevMode) {
|
||||
ScanLogStore.appendScanSummary(
|
||||
attemptCount = attempts,
|
||||
spotVolumes = finalSpotVolumes,
|
||||
trimmedMeanMl = trimmedResult,
|
||||
successfulWalls = spotSuccessfulWalls,
|
||||
successfulChannels = spotSuccessfulChannels,
|
||||
antOffsetMm = appState.piezoSettings.antOffsetMm,
|
||||
)
|
||||
}
|
||||
spotVolumes.clear()
|
||||
isSpotInProgress = false
|
||||
handler.postDelayed({ spotCooldown = false }, 1000)
|
||||
@@ -1078,8 +1222,33 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
// BLE Debug Log
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
BleDebugPanel(bleManager = bleManager)
|
||||
|
||||
// Scan Log — Single Scan captured diagnostics (xbench mbb-log format)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = { showScanLogDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073CF))
|
||||
) {
|
||||
Text(
|
||||
"Scan Log (${scanLogState.size})",
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
} // end scrollable Column
|
||||
} // end BoxWithConstraints
|
||||
|
||||
if (showScanLogDialog) {
|
||||
ScanLogDialog(
|
||||
log = scanLogState,
|
||||
onClear = { ScanLogStore.clear() },
|
||||
onDismiss = { showScanLogDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Floating Settings Overlay ──
|
||||
if (showSettings) {
|
||||
@@ -1093,11 +1262,17 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground.copy(alpha = 0.97f)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.9f) // dev mode 추가 컨텐츠가 화면 밖으로 밀려 Save 버튼 안 보이는 문제 방지
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
.shadow(12.dp, RoundedCornerShape(16.dp))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// Max Volume
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Bladder Capacity", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||
@@ -1112,6 +1287,38 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
||||
)
|
||||
|
||||
|
||||
// Ant Offset — 개발자 모드 전용 숫자 입력. BV 계산 시 전위벽 위치를 probe 쪽으로
|
||||
// 이 mm만큼 당기는 보정값. 0~20 mm. 1 mm = 약 0.52 sample이므로 정수 truncation에
|
||||
// 유의 (2 mm마다 1 sample씩 chord가 늘어남).
|
||||
if (appState.isDevMode) {
|
||||
HorizontalDivider()
|
||||
var antOffsetText by remember {
|
||||
mutableStateOf(appState.piezoSettings.antOffsetMm.toString())
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Ant Offset", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(
|
||||
value = antOffsetText,
|
||||
onValueChange = { newText ->
|
||||
val filtered = newText.filter { it.isDigit() }.take(2)
|
||||
antOffsetText = filtered
|
||||
val parsed = filtered.toIntOrNull() ?: 0
|
||||
val clamped = parsed.coerceIn(0, 20)
|
||||
appState.piezoSettings =
|
||||
appState.piezoSettings.copy(antOffsetMm = clamped)
|
||||
},
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||
),
|
||||
singleLine = true,
|
||||
suffix = { Text("mm", fontSize = 12.sp) },
|
||||
modifier = Modifier.width(110.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Catheterization Threshold
|
||||
@@ -1303,7 +1510,26 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Save / Cancel
|
||||
// Version (BuildConfig.VERSION_NAME) — read-only
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Version", fontSize = 13.sp, color = MlSecondaryText)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
com.example.medilightv2android.BuildConfig.VERSION_NAME,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MlPrimary,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Save / Cancel — Save가 piezoSettings을 영구 저장 + state 전파 (다음 측정에 반영)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
@@ -1314,7 +1540,10 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { showSettings = false },
|
||||
onClick = {
|
||||
appState.savePiezoSettingsNow()
|
||||
showSettings = false
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
||||
) {
|
||||
@@ -1330,8 +1559,8 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
if (showRealignPrompt) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRealignPrompt = false },
|
||||
title = { Text("Position lost") },
|
||||
text = { Text("Sensor may have shifted. Restart from alignment.") },
|
||||
title = { Text("Position Lost or Insufficient Gel") },
|
||||
text = { Text("Position lost or gel may be insufficient. Please check.") },
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
showRealignPrompt = false
|
||||
|
||||
+39
-25
@@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -252,12 +253,24 @@ fun PlacementGuideView(appState: AppState) {
|
||||
.then(if (appState.isDevMode) Modifier.height(400.dp) else Modifier.weight(1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Sensor PNG → ImageBitmap (Canvas drawImage로 그려 oval 중심과 정렬)
|
||||
val sensorBitmap = remember {
|
||||
android.graphics.BitmapFactory.decodeResource(
|
||||
context.resources,
|
||||
com.example.medilightv2android.R.drawable.sensor_device,
|
||||
).asImageBitmap()
|
||||
}
|
||||
// 하복부 실루엣 (Canvas — CKLaw body silhouette)
|
||||
val bodyAlpha = 0.9f // 항상 동일 (#5)
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val cx = w / 2f
|
||||
// Cap body silhouette width by height-derived budget so the
|
||||
// figure stays portrait-proportioned on wide screens / 가로 모드.
|
||||
// Portrait phone: bodyW == w (no change). Tablet / landscape:
|
||||
// bodyW < w, body stays centered with cx = w / 2.
|
||||
val bodyW = kotlin.math.min(w, h * 0.7f)
|
||||
val greenZone = isGreenZone
|
||||
|
||||
// Body silhouette palette
|
||||
@@ -271,9 +284,9 @@ fun PlacementGuideView(appState: AppState) {
|
||||
val waistY = h * 0.42f
|
||||
val hipY = h * 0.76f
|
||||
val pubisY = h * 0.96f
|
||||
val chestW = w * 0.42f
|
||||
val waistW = w * 0.32f
|
||||
val hipW = w * 0.46f
|
||||
val chestW = bodyW * 0.42f
|
||||
val waistW = bodyW * 0.32f
|
||||
val hipW = bodyW * 0.46f
|
||||
|
||||
// ── Torso silhouette — hourglass via cubic bezier ──
|
||||
val bodyPath = Path().apply {
|
||||
@@ -348,6 +361,26 @@ fun PlacementGuideView(appState: AppState) {
|
||||
style = Stroke(width = 1.5f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f)))
|
||||
)
|
||||
|
||||
// ── Sensor device (Canvas 안에서 drawImage — oval 중심에 정렬) ──
|
||||
// 너비를 bladder oval 직경 × 1.15 로 묶어 화면 비율 무관하게 비례.
|
||||
val sensorTargetW = (bladderRx * 2f) * 1.15f
|
||||
val sensorAspect = sensorBitmap.height.toFloat() / sensorBitmap.width.toFloat()
|
||||
val sensorTargetH = sensorTargetW * sensorAspect
|
||||
drawImage(
|
||||
image = sensorBitmap,
|
||||
srcOffset = androidx.compose.ui.unit.IntOffset.Zero,
|
||||
srcSize = androidx.compose.ui.unit.IntSize(sensorBitmap.width, sensorBitmap.height),
|
||||
dstOffset = androidx.compose.ui.unit.IntOffset(
|
||||
(cx - sensorTargetW / 2f).toInt(),
|
||||
(bladderCy - sensorTargetH / 2f).toInt(),
|
||||
),
|
||||
dstSize = androidx.compose.ui.unit.IntSize(
|
||||
sensorTargetW.toInt(),
|
||||
sensorTargetH.toInt(),
|
||||
),
|
||||
alpha = 0.95f,
|
||||
)
|
||||
|
||||
// ── 방향 화살표 (센서/타원 바깥, 충분한 간격) ──
|
||||
if (!greenZone) {
|
||||
val arrowColor = Color(0xFFFF5722)
|
||||
@@ -387,27 +420,7 @@ fun PlacementGuideView(appState: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
// 센서 기기 이미지 (방광 타원 중앙에 정렬)
|
||||
// 센서 — 화면 비율 기반 크기/위치 (태블릿 대응)
|
||||
// 타원 중심 ≈ 높이 72%. BiasAlignment(0, 0.44) ≈ 72%
|
||||
// 센서 너비 = 박스 너비의 35%
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||
modifier = Modifier.matchParentSize()
|
||||
) {
|
||||
val sensorW = maxWidth * 0.35f
|
||||
val sensorH = sensorW * 0.625f
|
||||
androidx.compose.foundation.Image(
|
||||
painter = androidx.compose.ui.res.painterResource(com.example.medilightv2android.R.drawable.sensor_device),
|
||||
contentDescription = "Sensor",
|
||||
modifier = Modifier
|
||||
.size(width = sensorW, height = sensorH)
|
||||
.align(androidx.compose.ui.BiasAlignment(0f, 0.55f)),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
|
||||
alpha = 0.95f
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// (sensor 이미지는 위 Canvas drawImage 로 이동 — body silhouette과 동일 좌표계)
|
||||
}
|
||||
|
||||
// (힌트 텍스트는 Step 아래, 몸 위에 배치됨)
|
||||
@@ -785,7 +798,7 @@ fun PlacementGuideView(appState: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
// Start button (초기 / 재시작 대기 시)
|
||||
// Start button (초기 / 재시작 대기 시). 화면 진입 직후엔 항상 클릭 가능.
|
||||
if (waitingForStart) {
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -810,6 +823,7 @@ fun PlacementGuideView(appState: AppState) {
|
||||
currentRMm = null; bestRMm = 0.0
|
||||
sagittalArgMaxCh = null; lateralOffsetMm = null
|
||||
},
|
||||
enabled = true, // 화면 진입 / 재진입 시 무조건 클릭 가능 보장
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(96.dp),
|
||||
|
||||
@@ -34,7 +34,11 @@ object WdConfig {
|
||||
// Phantom : D(100.406 mm) / chord(52 samples) = 1.9309 mm/sp
|
||||
// K : c_eff / c_ref = 1.000
|
||||
// 결과적으로 amode_simulator 와 동일 좌표계 → 530 mL phantom 시 R≈50.20mm 회복.
|
||||
const val DPS_DEFAULT = 1.9309
|
||||
// Was `const val` — promoted to mutable so settings can override it at
|
||||
// runtime (UI "DPS" field syncs PiezoHW.distancePerSample → DPS_DEFAULT
|
||||
// so V41Detector's Geometry / AnatomicalGate use the same value the
|
||||
// 6-channel BV estimator uses).
|
||||
@Volatile @JvmField var DPS_DEFAULT: Double = 1.9309
|
||||
const val DELAY_MM_DEFAULT = 6.85
|
||||
const val C_EFF_DEFAULT = 1530.0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user