refactor: 데모 브랜치 패키지 통일 com.example.medilightv2android → com.medithings.vesiscan
사용자앱 (feature/tab-navigation) 과 패키지 이름 일치. namespace + applicationId 둘 다 com.medithings.vesiscan 로 변경 (기존 데모 앱은 재설치 필요). ## 변경 범위 - Kotlin 148 파일: package + import 문 (714 occurrences) - 디렉토리 이동: com/example/medilightv2android → com/medithings/vesiscan (main, test, androidTest 각각) - app/build.gradle.kts: namespace, applicationId - docs/FLAVOR_DEMO_STABLE.md: 참조 갱신 - V41DetectorCH4Test: BvDispatchResult.methodChosen → method (dto field name fix) ## 주의 - applicationId 가 바뀌므로 기존 데모 앱 (com.example.medilightv2android.demo) 은 Android 관점에서 다른 앱으로 취급 — 재설치 시 PIN/설정 초기화됨. - Fresh install 권장. 기존 앱 (com.example...) 은 별도로 uninstall 필요. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package com.medithings.vesiscan.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BatteryAlert
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
|
||||
/**
|
||||
* 배터리 잔량 경고 배너 — PlacementGuide / PiezoMonitoring 상단.
|
||||
*
|
||||
* 임계:
|
||||
* - 15% 이하 → 주황 경고 ("기기 배터리 부족 — 곧 충전하세요")
|
||||
* - 5% 이하 → 빨강 위급 ("기기 배터리 위급 — 측정이 갑자기 중단될 수 있음")
|
||||
* - 그 외 / 미연결 / 0% (아직 미수신) → 숨김
|
||||
*/
|
||||
@Composable
|
||||
fun BatteryWarningBanner(bleManager: BleManager, modifier: Modifier = Modifier) {
|
||||
val isConnected by bleManager.isConnected
|
||||
val batt by bleManager.batteryLevel
|
||||
if (!isConnected) return
|
||||
if (batt <= 0) return // 응답 받기 전엔 0 → false positive 방지
|
||||
if (batt > LOW_BATTERY_THRESHOLD) return
|
||||
|
||||
val critical = batt <= CRITICAL_BATTERY_THRESHOLD
|
||||
val bgColor = if (critical) Color(0xFFD32F2F) else Color(0xFFFF9500)
|
||||
val title = if (critical) stringResource(R.string.battery_critical_title) else stringResource(R.string.battery_low_title)
|
||||
val subtitle = if (critical)
|
||||
stringResource(R.string.battery_critical_desc, batt)
|
||||
else
|
||||
stringResource(R.string.battery_low_desc, batt)
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bgColor)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.BatteryAlert, "Battery", tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(subtitle, fontSize = 11.sp, color = Color.White.copy(alpha = 0.9f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val LOW_BATTERY_THRESHOLD = 15
|
||||
const val CRITICAL_BATTERY_THRESHOLD = 5
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.medithings.vesiscan.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.rive.runtime.kotlin.RiveAnimationView
|
||||
import app.rive.runtime.kotlin.core.Fit
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.models.UrgencyLevel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Bladdy Rive animation that responds to urgency level changes.
|
||||
* Used in monitoring views where bladder level is tracked.
|
||||
*
|
||||
* State machine inputs:
|
||||
* 0 = idle, 1 = wave, 3 = urgent, 4 = warning, 5 = eyes
|
||||
*/
|
||||
@Composable
|
||||
fun BladdyRiveView(
|
||||
urgency: UrgencyLevel,
|
||||
size: Dp = 100.dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val riveView = remember { mutableListOf<RiveAnimationView>() }
|
||||
|
||||
Box(
|
||||
modifier = modifier.size(size),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
RiveAnimationView(context).apply {
|
||||
setRiveResource(
|
||||
R.raw.bladdy_final12,
|
||||
stateMachineName = "State Machine 1",
|
||||
fit = Fit.CONTAIN
|
||||
)
|
||||
riveView.clear()
|
||||
riveView.add(this)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
}
|
||||
|
||||
// Respond to urgency changes
|
||||
LaunchedEffect(urgency) {
|
||||
val view = riveView.firstOrNull() ?: return@LaunchedEffect
|
||||
when (urgency) {
|
||||
UrgencyLevel.SAFE -> {
|
||||
view.setNumberState("State Machine 1", "state", 0f)
|
||||
// Random idle animations every 4 seconds
|
||||
while (true) {
|
||||
delay(4000)
|
||||
val randomState = listOf(0f, 1f, 5f).random()
|
||||
view.setNumberState("State Machine 1", "state", randomState)
|
||||
}
|
||||
}
|
||||
UrgencyLevel.WARNING -> {
|
||||
view.setNumberState("State Machine 1", "state", 4f)
|
||||
}
|
||||
UrgencyLevel.URGENT -> {
|
||||
view.setNumberState("State Machine 1", "state", 3f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bladdy Rive animation for idle/decorative use.
|
||||
* Plays random animations (idle, wave, eyes) every 5 seconds.
|
||||
* Used on home, onboarding, registration, sensor select, pin, scan screens.
|
||||
*/
|
||||
@Composable
|
||||
fun BladdyIdleRiveView(
|
||||
size: Dp = 220.dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val riveView = remember { mutableListOf<RiveAnimationView>() }
|
||||
|
||||
Box(
|
||||
modifier = modifier.size(size),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
RiveAnimationView(context).apply {
|
||||
setRiveResource(
|
||||
R.raw.bladdy_final12,
|
||||
stateMachineName = "State Machine 1",
|
||||
fit = Fit.CONTAIN
|
||||
)
|
||||
setNumberState("State Machine 1", "state", 0f)
|
||||
riveView.clear()
|
||||
riveView.add(this)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
}
|
||||
|
||||
// Random idle animations every 5 seconds
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(5000)
|
||||
val view = riveView.firstOrNull() ?: continue
|
||||
val randomState = listOf(0f, 1f, 5f).random()
|
||||
view.setNumberState("State Machine 1", "state", randomState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.medithings.vesiscan.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.ui.theme.MlPrimary
|
||||
|
||||
enum class BleReconnectionState {
|
||||
DISCONNECTED, RECONNECTING, FAILED
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BleDisconnectionBanner(
|
||||
state: BleReconnectionState,
|
||||
onReconnect: () -> Unit
|
||||
) {
|
||||
val bannerColor = when (state) {
|
||||
BleReconnectionState.DISCONNECTED -> Color(0xFFFF9500)
|
||||
BleReconnectionState.RECONNECTING -> MlPrimary
|
||||
BleReconnectionState.FAILED -> Color.Red
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.shadow(8.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.1f))
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(bannerColor)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
when (state) {
|
||||
BleReconnectionState.RECONNECTING -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text("!", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (state == BleReconnectionState.RECONNECTING) "Reconnecting..." else "Device Disconnected",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
Text(
|
||||
text = if (state == BleReconnectionState.FAILED) "Could not reconnect" else "Connection lost",
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
if (state != BleReconnectionState.RECONNECTING) {
|
||||
Button(
|
||||
onClick = onReconnect,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.White),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("Reconnect", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = bannerColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.medithings.vesiscan.ui.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.medithings.vesiscan.models.UrgencyLevel
|
||||
|
||||
@Composable
|
||||
fun DonutChartView(
|
||||
progress: Double,
|
||||
urgency: UrgencyLevel,
|
||||
lineWidth: Dp = 24.dp,
|
||||
size: Dp = 240.dp
|
||||
) {
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = progress.toFloat().coerceIn(0f, 1f),
|
||||
animationSpec = tween(600),
|
||||
label = "progress"
|
||||
)
|
||||
|
||||
val gradientColors = urgency.gradientColors
|
||||
val pct = (progress * 100).toInt()
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.semantics { contentDescription = "Bladder fill $pct percent, ${urgency.message}" }
|
||||
) {
|
||||
val strokeWidth = lineWidth.toPx()
|
||||
// Stroke 외곽이 Canvas 경계 밖으로 튀어나오는 걸 막기 위해 radius를 inner로 명시.
|
||||
// 회의 #5: 게이지가 도넛 외곽선을 이탈해 보이던 문제 해결.
|
||||
val outerR = this.size.minDimension / 2f
|
||||
val innerR = outerR - strokeWidth / 2f
|
||||
val center = androidx.compose.ui.geometry.Offset(this.size.width / 2f, this.size.height / 2f)
|
||||
|
||||
// Background track
|
||||
drawCircle(
|
||||
color = Color.Gray.copy(alpha = 0.12f),
|
||||
radius = innerR,
|
||||
center = center,
|
||||
style = Stroke(width = strokeWidth)
|
||||
)
|
||||
|
||||
// Progress arc — same radius
|
||||
if (animatedProgress > 0f) {
|
||||
val arcSize = androidx.compose.ui.geometry.Size(innerR * 2, innerR * 2)
|
||||
val arcOffset = androidx.compose.ui.geometry.Offset(
|
||||
center.x - innerR, center.y - innerR
|
||||
)
|
||||
drawArc(
|
||||
color = gradientColors.first(),
|
||||
startAngle = -90f,
|
||||
sweepAngle = 360f * animatedProgress,
|
||||
useCenter = false,
|
||||
topLeft = arcOffset,
|
||||
size = arcSize,
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.medithings.vesiscan.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* 펌웨어 상태 배너 — PlacementGuide / PiezoMonitoring 상단에 노출.
|
||||
*
|
||||
* 표시 케이스:
|
||||
* (1) 펌웨어 구버전 (build < MIN_FIRMWARE_BUILD) — 주황 경고
|
||||
* (2) 연결 후 8초 넘게 mid? 응답 미수신 — 빨강 경고 (펌웨어가 mid 미지원)
|
||||
* (3) 정상 또는 미연결 — 안 보임
|
||||
*/
|
||||
@Composable
|
||||
fun FirmwareWarningBanner(bleManager: BleManager, modifier: Modifier = Modifier) {
|
||||
val fw by bleManager.firmwareVersion
|
||||
val isConnected by bleManager.isConnected
|
||||
val lastRxAt by bleManager.lastBleRxAt
|
||||
|
||||
// 미연결 상태에선 펌웨어 배너 일절 노출 X. dev bypass / demo mode / 이전 세션
|
||||
// 잔여 firmwareVersion 값으로 인한 false positive 방지.
|
||||
if (!isConnected) return
|
||||
|
||||
// (2) 연결됐는데 일정 시간 동안 펌웨어 미수신 추적
|
||||
var timedOut by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(isConnected, fw) {
|
||||
timedOut = false
|
||||
if (isConnected && fw.isEmpty()) {
|
||||
delay(8_000)
|
||||
if (bleManager.isConnected.value && bleManager.firmwareVersion.value.isEmpty()) {
|
||||
timedOut = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deviceAlive 재계산용 매초 tick
|
||||
var tick by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(isConnected) {
|
||||
while (isConnected) {
|
||||
delay(1000)
|
||||
tick++
|
||||
}
|
||||
}
|
||||
@Suppress("UNUSED_EXPRESSION") tick
|
||||
// 최근 5초 내 BLE 데이터 수신 있으면 기기 alive — "기기 응답 없음" false positive 차단.
|
||||
val deviceAlive = lastRxAt > 0 && (System.currentTimeMillis() - lastRxAt) < 5_000
|
||||
|
||||
val outdated = bleManager.isFirmwareOutdated
|
||||
val showTimedOut = timedOut && !deviceAlive
|
||||
if (!outdated && !showTimedOut) return
|
||||
|
||||
val bgColor = if (outdated) Color(0xFFFF9500) else Color(0xFFD32F2F)
|
||||
val title = if (outdated) stringResource(R.string.firmware_outdated_title) else stringResource(R.string.firmware_no_response_title)
|
||||
// timedOut(mid? 미수신) 케이스는 사용자에게 mid?/펌웨어 호환 같은 기술 용어가 무의미.
|
||||
// outdated 일 때만 상세 안내, timedOut 은 타이틀만 노출.
|
||||
val subtitle: String? = if (outdated) {
|
||||
stringResource(R.string.firmware_outdated_desc, fw, BleManager.MIN_FIRMWARE_BUILD)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bgColor)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Warning, "Warning", tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
if (subtitle != null) {
|
||||
Text(subtitle, fontSize = 11.sp, color = Color.White.copy(alpha = 0.9f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* LockableSlider — 표준 Material3 Slider 를 잠금 아이콘으로 감싼 wrapper.
|
||||
*
|
||||
* 사용 의도:
|
||||
* 설정 패널 등 ScrollView 안의 슬라이더가 위아래 스크롤 시 손가락 끌림에 의해
|
||||
* 값이 의도치 않게 바뀌는 문제 차단. 사용자가 잠금 아이콘을 1회 탭하여 활성화한
|
||||
* 뒤에만 슬라이더가 반응.
|
||||
*
|
||||
* 동작:
|
||||
* - 기본 상태: 잠김 (회색 lock 아이콘 + slider disabled)
|
||||
* - 잠금 아이콘 tap → 활성화 (녹색 lock_open 아이콘 + slider enabled)
|
||||
* - 다시 tap → 재잠금
|
||||
* - 잠금 상태에서는 slider drag 무시 → 위아래 스크롤 안전
|
||||
*
|
||||
* UX 톤:
|
||||
* "1회 터치 후 조정" 패턴 — 의도된 조작만 허용.
|
||||
*/
|
||||
package com.medithings.vesiscan.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderColors
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.medithings.vesiscan.R
|
||||
|
||||
@Composable
|
||||
fun LockableSlider(
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
|
||||
steps: Int = 0,
|
||||
onValueChangeFinished: (() -> Unit)? = null,
|
||||
colors: SliderColors = SliderDefaults.colors(),
|
||||
) {
|
||||
var unlocked by remember { mutableStateOf(false) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
IconButton(
|
||||
onClick = { unlocked = !unlocked },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (unlocked) Icons.Default.LockOpen else Icons.Default.Lock,
|
||||
contentDescription = if (unlocked) stringResource(R.string.slider_unlocked_desc) else stringResource(R.string.slider_locked_desc),
|
||||
tint = if (unlocked) Color(0xFF4CAF50) else Color.Gray.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = if (unlocked) onValueChange else { _ -> },
|
||||
onValueChangeFinished = onValueChangeFinished,
|
||||
valueRange = valueRange,
|
||||
steps = steps,
|
||||
colors = colors,
|
||||
enabled = unlocked,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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.medithings.vesiscan.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))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.medithings.vesiscan.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Material theme colors
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
|
||||
// MediLight theme colors
|
||||
val MlPrimary = Color(0xFF008FFF)
|
||||
val MlTeal = Color(0xFF00DB9F)
|
||||
val MlBackground = Color(0xFFF5F8FF)
|
||||
val MlCardBackground = Color.White
|
||||
val MlSecondaryText = Color(0xFF80808C)
|
||||
val MlInputBackground = Color(0xFFF5F8FF)
|
||||
val MlInputBorder = Color(0xFF00DB9F)
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.medithings.vesiscan.ui.theme
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
fun Modifier.pressScale(onClick: () -> Unit): Modifier = composed {
|
||||
var pressed by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, label = "pressScale")
|
||||
val alpha by animateFloatAsState(if (pressed) 0.85f else 1f, label = "pressAlpha")
|
||||
this
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
pressed = true
|
||||
tryAwaitRelease()
|
||||
pressed = false
|
||||
},
|
||||
onTap = { onClick() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object DesignTokens {
|
||||
// Button heights (iOS: buttonPrimary=56, buttonSecondary=48)
|
||||
val ButtonHeight = 56.dp
|
||||
val ButtonHeightSecondary = 48.dp
|
||||
|
||||
// Touch targets (iOS: minTouchTarget=44)
|
||||
val MinTouchTarget = 44.dp
|
||||
|
||||
// Corner radius (iOS: small=8, medium=12, large=16)
|
||||
val CornerRadiusSmall = 8.dp
|
||||
val CornerRadiusMedium = 12.dp
|
||||
val CornerRadiusLarge = 16.dp
|
||||
|
||||
// Spacing (iOS: xs=4, sm=8, md=16, lg=24, xl=32)
|
||||
val SpacingXs = 4.dp
|
||||
val SpacingSm = 8.dp
|
||||
val SpacingMd = 16.dp
|
||||
val SpacingLg = 24.dp
|
||||
val SpacingXl = 32.dp
|
||||
|
||||
// Icon sizes (iOS: small=14, medium=18, large=24)
|
||||
val IconSmall = 14.dp
|
||||
val IconMedium = 18.dp
|
||||
val IconLarge = 24.dp
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.medithings.vesiscan.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun Medilightv2androidTheme(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
// Always use light color scheme — app is designed for light mode only.
|
||||
// Dynamic color and dark theme disabled to prevent Samsung Force Dark Mode
|
||||
// and Material You from overriding custom MediLight colors.
|
||||
MaterialTheme(
|
||||
colorScheme = LightColorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.medithings.vesiscan.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.medithings.vesiscan.ui.views.calibration
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.managers.NirsManager
|
||||
import com.medithings.vesiscan.models.NirsMagResponse
|
||||
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CalibrationView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
val bleManager = remember { BleManager.getInstance(context) }
|
||||
val nirsManager = remember { NirsManager.instance }
|
||||
val handler = remember { Handler(Looper.getMainLooper()) }
|
||||
|
||||
val initMsg = stringResource(R.string.calibration_initializing)
|
||||
val completeMsg = stringResource(R.string.calibration_complete_status)
|
||||
val noResponseMsg = stringResource(R.string.calibration_no_response)
|
||||
val poweringMsg = stringResource(R.string.calibration_powering_on)
|
||||
val activatingMsg = stringResource(R.string.calibration_activating)
|
||||
val readingGainMsg = stringResource(R.string.calibration_reading_gain)
|
||||
|
||||
var statusMessage by remember { mutableStateOf(initMsg) }
|
||||
var isComplete by remember { mutableStateOf(false) }
|
||||
var isFailed by remember { mutableStateOf(false) }
|
||||
var didReceiveResponse by remember { mutableStateOf(false) }
|
||||
|
||||
val isServiceReady by bleManager.isServiceReady
|
||||
|
||||
fun completeCalibration() {
|
||||
if (isComplete) return
|
||||
statusMessage = completeMsg
|
||||
isComplete = true
|
||||
}
|
||||
|
||||
fun failCalibration() {
|
||||
if (isComplete || isFailed) return
|
||||
statusMessage = noResponseMsg
|
||||
isFailed = true
|
||||
}
|
||||
|
||||
fun startCalibration() {
|
||||
statusMessage = poweringMsg
|
||||
|
||||
bleManager.onNirsPowerOnReceived = {
|
||||
didReceiveResponse = true
|
||||
statusMessage = activatingMsg
|
||||
handler.postDelayed({ bleManager.sendMqqQuery() }, 2000)
|
||||
}
|
||||
|
||||
bleManager.onNirsSensorActivated = {
|
||||
statusMessage = readingGainMsg
|
||||
handler.postDelayed({ bleManager.sendMagQuery() }, 300)
|
||||
}
|
||||
|
||||
bleManager.onNirsMagReceived = { data ->
|
||||
NirsMagResponse.parse(data)?.let { response ->
|
||||
nirsManager.setCalibrationGain(response)
|
||||
}
|
||||
completeCalibration()
|
||||
}
|
||||
|
||||
bleManager.sendNirsPowerOn()
|
||||
|
||||
// Timeout
|
||||
handler.postDelayed({
|
||||
if (didReceiveResponse) completeCalibration() else failCalibration()
|
||||
}, 6000)
|
||||
}
|
||||
|
||||
fun retryCalibration() {
|
||||
isFailed = false
|
||||
isComplete = false
|
||||
didReceiveResponse = false
|
||||
statusMessage = initMsg
|
||||
startCalibration()
|
||||
}
|
||||
|
||||
LaunchedEffect(isServiceReady) {
|
||||
if (isServiceReady) startCalibration()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Bladdy with checkmark
|
||||
Box {
|
||||
BladdyIdleRiveView(size = 160.dp)
|
||||
|
||||
if (isComplete) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MlTeal,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = (-10).dp, y = (-10).dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = when {
|
||||
isFailed -> stringResource(R.string.calibration_failed)
|
||||
isComplete -> stringResource(R.string.calibration_complete)
|
||||
else -> stringResource(R.string.calibration_in_progress)
|
||||
},
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isFailed) Color.Red else Color.Unspecified
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = statusMessage,
|
||||
fontSize = 16.sp,
|
||||
color = MlSecondaryText,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 32.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (isFailed) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = { retryCalibration() },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||
RoundedCornerShape(16.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Refresh, null, tint = Color.White)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.retry), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { appState.calibrationComplete() },
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
Text(stringResource(R.string.calibration_skip), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
} else if (isComplete) {
|
||||
Button(
|
||||
onClick = { appState.calibrationComplete() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.height(56.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||
RoundedCornerShape(16.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(R.string.calibration_continue), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
package com.medithings.vesiscan.ui.views.clinical
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MyLocation
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppScreen
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.models.*
|
||||
import com.medithings.vesiscan.services.ClinicalSessionStore
|
||||
import com.medithings.vesiscan.services.labdb.LabdbClient
|
||||
import com.medithings.vesiscan.services.labdb.LabdbCredentials
|
||||
import com.medithings.vesiscan.services.labdb.LabdbUploader
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Clinical R&D 측정 화면 — "한 측정 = 한 라벨 = 한 폴더" 모델.
|
||||
*
|
||||
* 흐름:
|
||||
* 1. BLE 연결 (미연결 시 Connect 버튼 → DeviceScan)
|
||||
* 2. 측정 라벨 입력 (Subject/Device/Posture/Step/Examiner/Notes)
|
||||
* 3. Start Measurement → PlacementGuide(alignment) or PiezoMonitoring(BV)
|
||||
* 4. 측정 후 Back → 자동 endMeasurement → 폴더 finalize
|
||||
* 5. 다음 측정 라벨 입력 또는 종료
|
||||
*/
|
||||
@Composable
|
||||
fun ClinicalHomeView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
val bleManager = remember { BleManager.getInstance(context) }
|
||||
val isConnected by bleManager.isConnected
|
||||
val deviceName = bleManager.connectedDeviceName.value
|
||||
|
||||
// 진입 시 flag set, 화면 떠날 때 unset (DEVICE_SCAN/측정화면 라우팅용)
|
||||
DisposableEffect(Unit) {
|
||||
ClinicalSessionStore.inClinicalFlow = true
|
||||
onDispose {
|
||||
// 측정 진행을 위해 다른 화면(DEVICE_SCAN/PLACEMENT_GUIDE/PIEZO_MONITORING)으로 갈 땐 flag 유지.
|
||||
// HOME으로 복귀 시에만 unset → MainActivity의 HOME 진입 시 처리.
|
||||
}
|
||||
}
|
||||
|
||||
// 라벨 입력 state — 마지막 입력값 기억 (Store에서 자동 채움)
|
||||
// 마지막 선택값 유지 — 매 측정마다 0cm/SUPINE으로 리셋되는 불편 제거 (회의 결정)
|
||||
var posture by remember { mutableStateOf(ClinicalSessionStore.lastPosture) }
|
||||
var step by remember { mutableStateOf(ClinicalSessionStore.lastStep) }
|
||||
var examiner by remember { mutableStateOf(ClinicalSessionStore.lastExaminer) }
|
||||
var notes by remember { mutableStateOf(ClinicalSessionStore.lastNotes) }
|
||||
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC 버튼)
|
||||
var subjectType by remember { mutableStateOf(ClinicalSessionStore.lastSubjectType) }
|
||||
// 카테고리 내 추가 식별자 (예: "500mL", "S001") — optional
|
||||
var subject by remember { mutableStateOf(ClinicalSessionStore.lastSubject) }
|
||||
var trueVolText by remember { mutableStateOf(ClinicalSessionStore.lastTrueVolumeMl) }
|
||||
var abdomenText by remember { mutableStateOf(ClinicalSessionStore.lastAbdomenThicknessMm) }
|
||||
|
||||
val lastFolder = ClinicalSessionStore.lastFinalizedFolder
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// labdb 상태 — 화면 진입 시 + 사용자 액션 후 refresh
|
||||
var labdbApiKey by remember { mutableStateOf(LabdbCredentials.apiKey) }
|
||||
var labdbStatus by remember { mutableStateOf(LabdbCredentials.lastStatus ?: "unknown") }
|
||||
var labdbBusy by remember { mutableStateOf(false) }
|
||||
var labdbMessage by remember { mutableStateOf<String?>(null) }
|
||||
// 마지막 측정 업로드 상태 — Last saved 카드용
|
||||
var lastUploaded by remember(lastFolder) {
|
||||
mutableStateOf(lastFolder?.let { LabdbUploader.isUploaded(it) } ?: false)
|
||||
}
|
||||
var lastUploadError by remember(lastFolder) {
|
||||
mutableStateOf(lastFolder?.let { LabdbUploader.lastError(it) })
|
||||
}
|
||||
var uploadBusy by remember { mutableStateOf(false) }
|
||||
var showUnpairConfirm by remember { mutableStateOf(false) }
|
||||
var showDeleteSavedConfirm by remember { mutableStateOf(false) }
|
||||
var deleteBusy by remember { mutableStateOf(false) }
|
||||
|
||||
// 진입 시 status 자동 확인 (apiKey 있을 때만)
|
||||
LaunchedEffect(Unit) {
|
||||
if (LabdbCredentials.isRegistered) {
|
||||
labdbBusy = true
|
||||
runCatching { LabdbClient.checkStatus() }
|
||||
.onSuccess {
|
||||
labdbStatus = it.status
|
||||
labdbApiKey = LabdbCredentials.apiKey
|
||||
}
|
||||
.onFailure {
|
||||
labdbMessage = it.message
|
||||
labdbApiKey = LabdbCredentials.apiKey
|
||||
}
|
||||
labdbBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MlBackground)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Top bar
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = {
|
||||
ClinicalSessionStore.inClinicalFlow = false
|
||||
appState.currentScreen = AppScreen.HOME
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Clinical Measurement", fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// BLE 연결 상태
|
||||
Card(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isConnected) Color(0xFF4CAF50).copy(alpha = 0.15f)
|
||||
else Color(0xFFFF9800).copy(alpha = 0.15f)
|
||||
)
|
||||
) {
|
||||
// 재연결/새 페어링 진행 중 — connectGatt 호출부터 STATE_CONNECTED까지 1~5초 걸리는 동안
|
||||
// "BLE Not Connected"가 보이는 깜박임 방지.
|
||||
val isConnecting by bleManager.isConnecting
|
||||
val isReconnecting by bleManager.isReconnecting
|
||||
val showConnecting = !isConnected && (isConnecting || isReconnecting)
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isConnected) {
|
||||
Icon(Icons.Default.CheckCircle, "Connected",
|
||||
tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("BLE Connected", fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||
Text(bleManager.connectedDeviceName.value,
|
||||
fontSize = 11.sp, color = MlSecondaryText)
|
||||
// 펌웨어 버전 (mid? → rid: 응답 파싱, 연결 직후 자동 채워짐)
|
||||
val fwVersion by bleManager.firmwareVersion
|
||||
if (fwVersion.isNotEmpty()) {
|
||||
Text("FW: $fwVersion",
|
||||
fontSize = 11.sp, color = MlSecondaryText,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
bleManager.disconnect()
|
||||
appState.isDeviceConnected = false
|
||||
},
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFF555555)),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(32.dp)
|
||||
) { Text("Disconnect", fontSize = 11.sp) }
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = { showUnpairConfirm = true },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFE53935)),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(32.dp)
|
||||
) { Text("Unpair", fontSize = 11.sp) }
|
||||
}
|
||||
} else if (showConnecting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = MlPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("BLE Connecting…", fontWeight = FontWeight.Bold, fontSize = 14.sp,
|
||||
color = MlPrimary, modifier = Modifier.weight(1f))
|
||||
} else {
|
||||
Text("BLE Not Connected", fontWeight = FontWeight.Bold, fontSize = 14.sp,
|
||||
color = Color(0xFFFF9800), modifier = Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = { appState.currentScreen = AppScreen.DEVICE_SCAN },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
||||
) { Text("Connect", color = Color.White) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// labdb 서버 상태/등록 카드
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val labdbBg = when {
|
||||
!LabdbCredentials.isRegistered -> Color(0xFF9E9E9E).copy(alpha = 0.15f)
|
||||
labdbStatus == "active" -> Color(0xFF2196F3).copy(alpha = 0.12f)
|
||||
labdbStatus == "pending" -> Color(0xFFFF9800).copy(alpha = 0.15f)
|
||||
else -> Color(0xFFE53935).copy(alpha = 0.12f)
|
||||
}
|
||||
Card(shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = labdbBg)) {
|
||||
Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("labdb.medithings.net", fontSize = 11.sp, color = MlSecondaryText)
|
||||
Text(
|
||||
when {
|
||||
!LabdbCredentials.isRegistered -> "Not registered"
|
||||
labdbBusy -> "Checking…"
|
||||
else -> "Status: $labdbStatus"
|
||||
},
|
||||
fontSize = 14.sp, fontWeight = FontWeight.Bold
|
||||
)
|
||||
LabdbCredentials.deviceId?.let {
|
||||
Text("deviceId: $it", fontSize = 10.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
if (!LabdbCredentials.isRegistered) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (labdbBusy) return@Button
|
||||
labdbBusy = true
|
||||
labdbMessage = null
|
||||
scope.launch {
|
||||
runCatching {
|
||||
LabdbClient.register(
|
||||
context,
|
||||
appVersion = try {
|
||||
context.packageManager
|
||||
.getPackageInfo(context.packageName, 0)
|
||||
.versionName ?: "unknown"
|
||||
} catch (_: Exception) { "unknown" }
|
||||
)
|
||||
}.onSuccess {
|
||||
labdbApiKey = it.apiKey
|
||||
labdbStatus = it.status
|
||||
labdbMessage = it.message
|
||||
Toast.makeText(context,
|
||||
"Registered. Awaiting approval.",
|
||||
Toast.LENGTH_LONG).show()
|
||||
}.onFailure {
|
||||
labdbMessage = it.message
|
||||
}
|
||||
labdbBusy = false
|
||||
}
|
||||
},
|
||||
enabled = !labdbBusy,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
||||
) { Text("Register", color = Color.White, fontSize = 12.sp) }
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (labdbBusy) return@OutlinedButton
|
||||
labdbBusy = true
|
||||
labdbMessage = null
|
||||
scope.launch {
|
||||
runCatching { LabdbClient.checkStatus() }
|
||||
.onSuccess {
|
||||
labdbStatus = it.status
|
||||
labdbApiKey = LabdbCredentials.apiKey
|
||||
}
|
||||
.onFailure {
|
||||
labdbMessage = it.message
|
||||
labdbApiKey = LabdbCredentials.apiKey
|
||||
}
|
||||
labdbBusy = false
|
||||
}
|
||||
},
|
||||
enabled = !labdbBusy
|
||||
) { Text("Refresh", fontSize = 12.sp) }
|
||||
}
|
||||
}
|
||||
labdbMessage?.let {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(it, fontSize = 11.sp, color = Color(0xFFE53935))
|
||||
}
|
||||
if (LabdbCredentials.isRegistered && labdbStatus == "pending") {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text("admin@medithings.net 승인 후 자동 업로드됩니다.",
|
||||
fontSize = 11.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 직전 측정 결과 + 업로드 상태/재시도
|
||||
if (lastFolder != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val savedBg = when {
|
||||
lastUploaded -> Color(0xFF4CAF50).copy(alpha = 0.10f)
|
||||
lastUploadError != null -> Color(0xFFE53935).copy(alpha = 0.10f)
|
||||
else -> Color(0xFF4CAF50).copy(alpha = 0.06f)
|
||||
}
|
||||
Card(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = savedBg)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.CheckCircle, null,
|
||||
tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Last saved", fontSize = 11.sp, color = MlSecondaryText)
|
||||
Text(lastFolder.name, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
when {
|
||||
lastUploaded -> "✓ labdb 업로드 완료"
|
||||
lastUploadError != null -> "✗ 업로드 실패: $lastUploadError"
|
||||
uploadBusy -> "업로드 중…"
|
||||
else -> "로컬 저장만 (서버 미업로드)"
|
||||
},
|
||||
fontSize = 11.sp,
|
||||
color = when {
|
||||
lastUploaded -> Color(0xFF4CAF50)
|
||||
lastUploadError != null -> Color(0xFFE53935)
|
||||
else -> MlSecondaryText
|
||||
}
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (!lastUploaded && LabdbCredentials.isRegistered) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (uploadBusy) return@Button
|
||||
uploadBusy = true
|
||||
scope.launch {
|
||||
runCatching { LabdbUploader.uploadBlocking(lastFolder) }
|
||||
.onSuccess {
|
||||
lastUploaded = true
|
||||
lastUploadError = null
|
||||
Toast.makeText(context,
|
||||
"Uploaded: ${it.inserted}/${it.totalRecords}",
|
||||
Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
.onFailure {
|
||||
lastUploadError = it.message ?: it.javaClass.simpleName
|
||||
// status도 갱신 (401/403/404 케이스 반영)
|
||||
labdbApiKey = LabdbCredentials.apiKey
|
||||
labdbStatus = LabdbCredentials.lastStatus ?: "unknown"
|
||||
}
|
||||
uploadBusy = false
|
||||
}
|
||||
},
|
||||
enabled = !uploadBusy,
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
||||
) { Text("Upload", color = Color.White, fontSize = 12.sp) }
|
||||
}
|
||||
// Delete — 잘못 측정 시 폐기 (로컬 + 서버)
|
||||
OutlinedButton(
|
||||
onClick = { showDeleteSavedConfirm = true },
|
||||
enabled = !deleteBusy && !uploadBusy,
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFE53935))
|
||||
) { Text("Delete", fontSize = 12.sp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 라벨 입력 폼
|
||||
Text("Measurement Label", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
LabeledSegmented("Posture", ClinicalPosture.values().toList(), posture, { posture = it }) { it.label }
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
LabeledSegmented("Step", ClinicalStep.values().toList(), step, { step = it }) { it.label }
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// 선택된 Step의 부가 설명 (치골 위로 N cm 등)
|
||||
Text(
|
||||
"→ ${step.description}",
|
||||
fontSize = 12.sp,
|
||||
color = MlPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 측정 대상 분류 — Phantom/Human/ETC 버튼 (회의 결정)
|
||||
LabeledSegmented(
|
||||
label = "Subject Type",
|
||||
options = com.medithings.vesiscan.models.SubjectType.values().toList(),
|
||||
selected = subjectType,
|
||||
onSelect = { subjectType = it }
|
||||
) { it.label }
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// 카테고리 내 추가 식별자 — 예: "500mL", "S001"
|
||||
val subjectFocus = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
|
||||
OutlinedTextField(
|
||||
value = subject,
|
||||
onValueChange = { subject = it },
|
||||
label = { Text("Subject (선택) — 예: 500mL, S001") },
|
||||
singleLine = true,
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
imeAction = androidx.compose.ui.text.input.ImeAction.Done
|
||||
),
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
|
||||
onDone = { subjectFocus?.hide() }
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = trueVolText,
|
||||
onValueChange = { trueVolText = it.filter { c -> c.isDigit() || c == '.' } },
|
||||
label = { Text("True Volume (mL)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = abdomenText,
|
||||
onValueChange = { abdomenText = it.filter { c -> c.isDigit() || c == '.' } },
|
||||
label = { Text("Abdomen (mm)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = examiner,
|
||||
onValueChange = { examiner = it },
|
||||
label = { Text("Examiner") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Notes — Examiner처럼 키보드 우하단 ✓(Done)으로 완료 가능
|
||||
val notesFocus = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text("Notes (선택)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
imeAction = androidx.compose.ui.text.input.ImeAction.Done
|
||||
),
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
|
||||
onDone = { notesFocus?.hide() }
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 라벨 preview — BLE 기기 이름이 device 식별자 역할
|
||||
val previewLabel = if (isConnected)
|
||||
"${deviceName}_${posture.name}_${step.name}"
|
||||
else "(BLE 연결 후 활성화)"
|
||||
Text("Label: $previewLabel",
|
||||
fontSize = 12.sp, color = MlSecondaryText, fontWeight = FontWeight.SemiBold)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 두 가지 진입점 — Live 6ch (기존) + Sensor Alignment (신규)
|
||||
val labdbOk = LabdbCredentials.isRegistered && labdbStatus == "active"
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Start Live Measurement (기존)
|
||||
Button(
|
||||
onClick = {
|
||||
ClinicalSessionStore.startMeasurement(
|
||||
context,
|
||||
ClinicalSession(
|
||||
deviceName = deviceName, posture = posture, step = step,
|
||||
examiner = examiner.trim(), notes = notes.trim(),
|
||||
subjectType = subjectType, subject = subject.trim(),
|
||||
trueVolumeMl = trueVolText.toDoubleOrNull(),
|
||||
abdomenThicknessMm = abdomenText.toDoubleOrNull(),
|
||||
mode = com.medithings.vesiscan.models.SessionMode.MEASUREMENT,
|
||||
)
|
||||
)
|
||||
appState.currentScreen = AppScreen.CLINICAL_LIVE
|
||||
},
|
||||
enabled = isConnected,
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Live 6ch", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||
}
|
||||
// Sensor Alignment — 신규. BLE + labdb 둘 다 OK 일 때만 활성.
|
||||
Button(
|
||||
onClick = {
|
||||
ClinicalSessionStore.startMeasurement(
|
||||
context,
|
||||
ClinicalSession(
|
||||
deviceName = deviceName, posture = posture, step = step,
|
||||
examiner = examiner.trim(), notes = notes.trim(),
|
||||
subjectType = subjectType, subject = subject.trim(),
|
||||
trueVolumeMl = trueVolText.toDoubleOrNull(),
|
||||
abdomenThicknessMm = abdomenText.toDoubleOrNull(),
|
||||
mode = com.medithings.vesiscan.models.SessionMode.ALIGNMENT,
|
||||
// 현재 dev 토글된 algo 그대로 — V1 (default) 또는 V2.
|
||||
alignmentAlgo = com.medithings.vesiscan.managers.GreenZoneConstants.alignmentAlgo.name,
|
||||
)
|
||||
)
|
||||
appState.enterAlignmentSession()
|
||||
},
|
||||
enabled = isConnected && labdbOk,
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C3AED))
|
||||
) {
|
||||
Icon(Icons.Default.MyLocation, null, tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Sensor Alignment", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text("→ BLE 연결 후 활성화", fontSize = 11.sp, color = Color(0xFFFF9800))
|
||||
} else if (!labdbOk) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text("→ Sensor Alignment 는 labdb 활성 필요", fontSize = 11.sp, color = Color(0xFFFF9800))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text("저장 위치: Downloads/VesiScan_Sessions/",
|
||||
fontSize = 10.sp, color = MlSecondaryText)
|
||||
}
|
||||
|
||||
// 잘못 측정한 폴더 삭제 confirm — 로컬 + labdb 서버 둘 다 (회의 결정: Yes 서버도)
|
||||
if (showDeleteSavedConfirm && lastFolder != null) {
|
||||
val targetFolder = lastFolder
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteSavedConfirm = false },
|
||||
title = { Text("Delete measurement?") },
|
||||
text = { Text("폴더 + labdb 서버 데이터를 모두 삭제합니다. 복구 불가.\n\n${targetFolder.name}") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
if (deleteBusy) return@TextButton
|
||||
showDeleteSavedConfirm = false
|
||||
deleteBusy = true
|
||||
scope.launch {
|
||||
// 1) labdb 서버 삭제 시도 (실패해도 로컬은 진행)
|
||||
val serverResult = runCatching {
|
||||
LabdbUploader.deleteSessionForFolder(targetFolder)
|
||||
}
|
||||
// 2) 로컬 폴더 삭제
|
||||
ClinicalSessionStore.deleteFinalizedFolder()
|
||||
lastUploaded = false
|
||||
lastUploadError = null
|
||||
val msg = if (serverResult.isSuccess) "Deleted (local + server)"
|
||||
else "Local deleted, server failed: ${serverResult.exceptionOrNull()?.message}"
|
||||
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
|
||||
deleteBusy = false
|
||||
}
|
||||
}) { Text("Delete", color = Color.Red) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteSavedConfirm = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showUnpairConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showUnpairConfirm = false },
|
||||
title = { Text("Delete pairing?") },
|
||||
text = { Text("기기 본딩을 삭제하고 연결을 끊습니다. 다음 측정 시 다시 페어링이 필요합니다.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showUnpairConfirm = false
|
||||
bleManager.disconnectAndUnbond()
|
||||
// BleManager.isConnected.value는 disconnectAndUnbond 내부에서 false로 reset되지만
|
||||
// appState.isDeviceConnected는 별도 — 명시적으로 동기화하지 않으면 stale true 상태가
|
||||
// 유지되어 HOME→Start 시 DeviceScan 건너뛰고 Personalization으로 직진 → 도넛차트
|
||||
// 진입 시 BLE 미연결 상태로 도착하는 버그 발생.
|
||||
appState.isDeviceConnected = false
|
||||
Toast.makeText(context, "Unpaired", Toast.LENGTH_SHORT).show()
|
||||
}) { Text("Delete", color = Color.Red) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showUnpairConfirm = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> LabeledSegmented(
|
||||
label: String,
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
onSelect: (T) -> Unit,
|
||||
labelOf: (T) -> String
|
||||
) {
|
||||
Column {
|
||||
Text(label, fontSize = 12.sp, color = MlSecondaryText, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
for (opt in options) {
|
||||
val isSel = opt == selected
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isSel) MlPrimary else Color.Gray.copy(alpha = 0.12f))
|
||||
.clickable { onSelect(opt) }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
labelOf(opt),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (isSel) Color.White else Color.Black
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package com.medithings.vesiscan.ui.views.clinical
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppScreen
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.ble.ImuSample
|
||||
import com.medithings.vesiscan.ble.PiezoChannelData
|
||||
import com.medithings.vesiscan.managers.ImuAnalysis
|
||||
import com.medithings.vesiscan.services.AdcCsvLogger
|
||||
import com.medithings.vesiscan.services.ClinicalSessionStore
|
||||
import com.medithings.vesiscan.services.ImuCsvLogger
|
||||
import com.medithings.vesiscan.services.MeasurementLogService
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Clinical Live 6채널 시각화 화면.
|
||||
*
|
||||
* vesiscan_test 의 run_stream 패턴을 안드로이드 앱 내에서 직접 구현:
|
||||
* - 600ms 간격으로 mtb? 송신 → 6채널 + IMU 자동 수집
|
||||
* - 2×3 grid 로 채널별 실시간 raw ADC 그래프
|
||||
* - 상단: 세션 정보 (Subject ID / Posture / Step / True Vol / Abdomen)
|
||||
* - 측정 상태 (cycle 번호, 6/6 채널 도착률, IMU 자세/흔들림)
|
||||
* - 하단 컨트롤: Pause/Resume / Discard / End
|
||||
* - 측정 중 매 사이클 자동 저장 (autoCapture=true 고정, 회의 결정으로 토글 제거)
|
||||
* - End: 세션 finalize → ClinicalHome 복귀
|
||||
*/
|
||||
@Composable
|
||||
fun ClinicalLiveView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
val bleManager = remember { BleManager.getInstance(context) }
|
||||
val session = ClinicalSessionStore.currentSession
|
||||
|
||||
// 세션 없으면 즉시 복귀 (잘못된 진입)
|
||||
if (session == null) {
|
||||
LaunchedEffect(Unit) { appState.currentScreen = AppScreen.CLINICAL_HOME }
|
||||
return
|
||||
}
|
||||
|
||||
// 측정 state
|
||||
var isMeasuring by remember { mutableStateOf(true) } // 시작 시 자동 측정
|
||||
// autoCapture는 항상 true — 토글 UI 제거됨 (회의 결정)
|
||||
val autoCapture = true
|
||||
var cycleCount by remember { mutableIntStateOf(0) }
|
||||
var capturedCount by remember { mutableIntStateOf(0) }
|
||||
var lastChannels by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
|
||||
var lastImu by remember { mutableStateOf<List<ImuSample>>(emptyList()) }
|
||||
var lastCycleMs by remember { mutableLongStateOf(0L) }
|
||||
|
||||
// BLE 콜백 — mtb 응답은 reb×6 + raa(piezo 완료) → rim(IMU 완료) 순서.
|
||||
// 한 사이클 = piezo 완료 + imu 완료. piezo가 먼저 오면 buffer에 보관, imu 도착 시 합쳐 push.
|
||||
var pendingPiezo by remember { mutableStateOf<List<PiezoChannelData>?>(null) }
|
||||
|
||||
// V4.1 wall detection — 채널별 ant/post peak 표시용 (사내 테스트 가시화 요청)
|
||||
val v41 = remember { com.medithings.vesiscan.walldetect.V41Detector() }
|
||||
var channelWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
bleManager.piezoCollector.onMultiChannelComplete = { channels ->
|
||||
lastChannels = channels
|
||||
pendingPiezo = channels
|
||||
// V4.1로 채널별 ant/post 즉시 계산 (실패해도 측정 자체는 영향 없음)
|
||||
runCatching {
|
||||
val adcMatrix = (0 until 6).map { ch ->
|
||||
channels.find { it.channel == ch }?.buffer?.map { it.toInt() } ?: List(100) { 0 }
|
||||
}
|
||||
val probe = com.medithings.vesiscan.walldetect.dto.ProbeProfileDto(
|
||||
name = com.medithings.vesiscan.managers.PiezoHW.presetName,
|
||||
rMm = 50.2, anteriorAnchorMm = 32.0,
|
||||
fsHz = 537500, cMmPerUs = 1.54,
|
||||
anglesDeg = com.medithings.vesiscan.managers.PiezoHW.degreeAll.toList()
|
||||
)
|
||||
val sweepInput = com.medithings.vesiscan.walldetect.dto.SweepInput(
|
||||
requestId = "clinical_${System.currentTimeMillis()}",
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
deviceId = bleManager.connectedDeviceName.value,
|
||||
sweepSeq = 0, probe = probe, adc = adcMatrix
|
||||
)
|
||||
val result = v41.detect(sweepInput)
|
||||
channelWalls = List(6) { ch ->
|
||||
val r = result.perChannel.firstOrNull { it.ch == ch }
|
||||
val a = r?.antIdx; val p = r?.postIdx
|
||||
if (a != null && p != null) Pair(a, p) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
bleManager.imuCollector.onComplete = { imuSamples ->
|
||||
lastImu = imuSamples
|
||||
cycleCount++
|
||||
lastCycleMs = System.currentTimeMillis()
|
||||
// 자동 저장: piezo + imu 묶어서 한 사이클로 누적 + 기존 CSV 저장 (재현용)
|
||||
val piezo = pendingPiezo
|
||||
if (autoCapture && piezo != null && ClinicalSessionStore.currentSession != null) {
|
||||
ClinicalSessionStore.addCycle(piezo, imuSamples)
|
||||
val rawADC = piezo.sortedBy { it.channel }.map { it.buffer }
|
||||
AdcCsvLogger.log(rawADC, null)
|
||||
ImuCsvLogger.log(imuSamples)
|
||||
capturedCount++
|
||||
}
|
||||
pendingPiezo = null
|
||||
}
|
||||
onDispose {
|
||||
bleManager.piezoCollector.onMultiChannelComplete = null
|
||||
bleManager.imuCollector.onComplete = null
|
||||
}
|
||||
}
|
||||
|
||||
// 측정 루프 — 600ms 간격
|
||||
LaunchedEffect(isMeasuring) {
|
||||
if (!isMeasuring) return@LaunchedEffect
|
||||
while (isMeasuring) {
|
||||
bleManager.sendMtb()
|
||||
delay(600)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MlBackground)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// ── Top bar ──
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
ClinicalSessionStore.endMeasurement(context)
|
||||
appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Live · ${session.label}",
|
||||
fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
val subj = "${session.subjectType.label} · "
|
||||
val tv = session.trueVolumeMl?.let { "TV=${it.toInt()}mL · " } ?: ""
|
||||
val ab = session.abdomenThicknessMm?.let { "Ab=${"%.0f".format(it)}mm" } ?: ""
|
||||
Text("$subj$tv$ab",
|
||||
fontSize = 11.sp, color = MlSecondaryText)
|
||||
}
|
||||
// Cycle counter
|
||||
Text("#$cycleCount ✓$capturedCount",
|
||||
fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
||||
}
|
||||
|
||||
// ── 6ch grid (2 rows × 3 cols) ──
|
||||
SixChannelGrid(
|
||||
channels = lastChannels,
|
||||
walls = channelWalls,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// ── IMU 정보 ──
|
||||
if (lastImu.isNotEmpty()) {
|
||||
val posture = remember(lastImu) { ImuAnalysis.estimatePosture(lastImu) }
|
||||
val tilt = remember(lastImu) { ImuAnalysis.tiltDegrees(lastImu) }
|
||||
val shake = remember(lastImu) { ImuAnalysis.shakeRmsDps(lastImu) }
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (shake > 30f) Color(0xFFFFEBEE) else Color(0xFFE8F5E9)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("IMU", fontSize = 11.sp, color = MlSecondaryText)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("$posture · tilt=${"%.0f".format(tilt)}° · shake=${"%.1f".format(shake)}dps",
|
||||
fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (shake > 30f) Color.Red else Color(0xFF2E7D32))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("${lastImu.size} samples", fontSize = 10.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// ── 컨트롤 영역 ──
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Pause/Resume
|
||||
Button(
|
||||
onClick = { isMeasuring = !isMeasuring },
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isMeasuring) Color(0xFFFF9800) else Color(0xFF4CAF50)
|
||||
)
|
||||
) {
|
||||
Icon(if (isMeasuring) Icons.Default.Stop else Icons.Default.PlayArrow,
|
||||
null, tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(if (isMeasuring) "Pause" else "Resume", color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// End Session / Discard 두 버튼 나란히
|
||||
var showDiscardConfirm by remember { mutableStateOf(false) }
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Discard — 측정값 폐기 + 폴더 삭제 (저장 안 함)
|
||||
OutlinedButton(
|
||||
onClick = { showDiscardConfirm = true },
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFE53935))
|
||||
) {
|
||||
Text("Discard", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
// End Session — 저장 + 자동 업로드
|
||||
Button(
|
||||
onClick = {
|
||||
isMeasuring = false
|
||||
val folder = ClinicalSessionStore.endMeasurement(context)
|
||||
folder?.let {
|
||||
Toast.makeText(context, "Saved ($capturedCount cycles): ${it.name}",
|
||||
Toast.LENGTH_LONG).show()
|
||||
}
|
||||
appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.85f))
|
||||
) {
|
||||
Text("End Session", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDiscardConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDiscardConfirm = false },
|
||||
title = { Text("Discard measurement?") },
|
||||
text = { Text("현재 사이클 ${capturedCount}개 + 폴더 전체 삭제. labdb 업로드 안 함. 복구 불가.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showDiscardConfirm = false
|
||||
isMeasuring = false
|
||||
val deleted = ClinicalSessionStore.discardAndDeleteFolder()
|
||||
deleted?.let {
|
||||
Toast.makeText(context, "Discarded: ${it.name}",
|
||||
Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||
}) { Text("Discard", color = Color.Red) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDiscardConfirm = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Cycle: ~600ms · Captured: $capturedCount · ${ClinicalSessionStore.currentLogDir()?.name ?: ""}",
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
fontSize = 10.sp, color = MlSecondaryText)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** 2×3 grid 6채널 라이브 차트 */
|
||||
@Composable
|
||||
private fun SixChannelGrid(
|
||||
channels: List<PiezoChannelData>,
|
||||
walls: List<Pair<Int, Int>?>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val channelColors = listOf(
|
||||
Color(0xFF1976D2), Color(0xFF388E3C), Color(0xFFE64A19),
|
||||
Color(0xFF7B1FA2), Color(0xFF00838F), Color(0xFFD81B60),
|
||||
)
|
||||
// 회의 #1: 2열 3행 grid로 복귀 + 셀 height 더 키움 → y축 길이 확보로 기울기 보임.
|
||||
// 화면 전체의 2/3 정도 차지 (3행 × 320dp ≈ 960dp).
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
for (row in 0 until 3) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
for (col in 0 until 2) {
|
||||
val ch = row * 2 + col
|
||||
val data = channels.find { it.channel == ch }
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ChannelChart(
|
||||
channelIdx = ch,
|
||||
buffer = data?.buffer ?: emptyList(),
|
||||
color = channelColors[ch],
|
||||
walls = walls.getOrNull(ch),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 한 채널 그래프 (raw ADC waveform + V4.1 ant/post 마커) */
|
||||
@Composable
|
||||
private fun ChannelChart(
|
||||
channelIdx: Int,
|
||||
buffer: List<UShort>,
|
||||
color: Color,
|
||||
walls: Pair<Int, Int>?,
|
||||
) {
|
||||
val antColor = Color(0xFF4CAF50) // green = anterior wall
|
||||
val postColor = Color(0xFFFF9800) // orange = posterior wall
|
||||
// 회의 #1: 2x3 grid에서 y축 더 늘림 → 기울기 잘 보임. 한 셀 height 240→320dp.
|
||||
Card(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
modifier = Modifier.fillMaxWidth().height(320.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(color)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("CH$channelIdx", fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (walls != null) {
|
||||
Text("a${walls.first}", fontSize = 14.sp, color = antColor, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("p${walls.second}", fontSize = 14.sp, color = postColor, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
if (buffer.isNotEmpty()) {
|
||||
val peak = buffer.maxOrNull()?.toInt() ?: 0
|
||||
Text("$peak", fontSize = 14.sp, color = MlSecondaryText, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
if (buffer.isEmpty()) return@Canvas
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val n = buffer.size
|
||||
if (n < 2) return@Canvas
|
||||
// y 범위: 0~4095 (12-bit ADC), 일부 여백
|
||||
val yMin = 0f
|
||||
val yMax = 4095f
|
||||
val yScale = h / (yMax - yMin)
|
||||
val xScale = w / (n - 1).toFloat()
|
||||
|
||||
// 가로 grid (4분할)
|
||||
for (i in 1..3) {
|
||||
val y = h * i / 4f
|
||||
drawLine(
|
||||
Color.LightGray.copy(alpha = 0.3f),
|
||||
Offset(0f, y), Offset(w, y),
|
||||
strokeWidth = 0.5f
|
||||
)
|
||||
}
|
||||
|
||||
// V4.1 ant/post 마커 (waveform 뒤에 그려서 가려지지 않게 alpha 조정)
|
||||
if (walls != null) {
|
||||
val (antIdx, postIdx) = walls
|
||||
if (antIdx in 0 until n) {
|
||||
val x = antIdx * xScale
|
||||
drawLine(antColor.copy(alpha = 0.75f), Offset(x, 0f), Offset(x, h), strokeWidth = 2.5f)
|
||||
}
|
||||
if (postIdx in 0 until n) {
|
||||
val x = postIdx * xScale
|
||||
drawLine(postColor.copy(alpha = 0.75f), Offset(x, 0f), Offset(x, h), strokeWidth = 2.5f)
|
||||
}
|
||||
}
|
||||
|
||||
// waveform — 굵게 (1.5 → 2.5)
|
||||
val path = Path()
|
||||
for (i in buffer.indices) {
|
||||
val x = i * xScale
|
||||
val y = h - (buffer[i].toInt() - yMin) * yScale
|
||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
}
|
||||
drawPath(path, color, style = Stroke(width = 2.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package com.medithings.vesiscan.ui.views.connection
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.BluetoothConnected
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.ble.BleDevice
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.models.SensorMode
|
||||
import com.medithings.vesiscan.services.KnownDevice
|
||||
import com.medithings.vesiscan.services.KnownDeviceStore
|
||||
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun DeviceScanView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
val bleManager = remember { BleManager.getInstance(context) }
|
||||
var connectingDeviceAddress by remember { mutableStateOf<String?>(null) }
|
||||
var permissionsGranted by remember { mutableStateOf(false) }
|
||||
|
||||
// Clinical 흐름에서만 다중 기기 저장/표시 — 일반 사용자는 fresh scan & pair.
|
||||
val isClinicalFlow = com.medithings.vesiscan.services.ClinicalSessionStore.inClinicalFlow
|
||||
var knownDevices by remember {
|
||||
mutableStateOf(if (isClinicalFlow) KnownDeviceStore.getAll(context) else emptyList())
|
||||
}
|
||||
var pendingDelete by remember { mutableStateOf<KnownDevice?>(null) }
|
||||
val knownAddresses = knownDevices.map { it.address }.toSet()
|
||||
val newDevices = bleManager.discoveredDevices
|
||||
.filter { it.name.startsWith("2025") || it.name.startsWith("VBT") }
|
||||
.filter { it.address !in knownAddresses }
|
||||
.sortedBy { it.name }
|
||||
|
||||
val isConnected by bleManager.isConnected
|
||||
val isServiceReady by bleManager.isServiceReady
|
||||
val isScanning by bleManager.isScanning
|
||||
val bluetoothEnabled by bleManager.bluetoothEnabled
|
||||
val connectionError by bleManager.connectionError
|
||||
|
||||
// BLE permissions
|
||||
val requiredPermissions = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
)
|
||||
} else {
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun checkPermissions(): Boolean {
|
||||
return requiredPermissions.all {
|
||||
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { results ->
|
||||
permissionsGranted = results.values.all { it }
|
||||
if (permissionsGranted) {
|
||||
bleManager.startScan()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (checkPermissions()) {
|
||||
permissionsGranted = true
|
||||
bleManager.startScan()
|
||||
} else {
|
||||
permissionLauncher.launch(requiredPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 준비 + 페어링(bonding) 완료 확인 후 다음 화면
|
||||
var showPairingHint by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(isServiceReady) {
|
||||
if (isServiceReady) {
|
||||
connectingDeviceAddress = null
|
||||
if (bleManager.isBonded) {
|
||||
// 이미 페어링됨 → 바로 진행
|
||||
appState.deviceConnected()
|
||||
} else {
|
||||
// 페어링 필요 → 안내 표시 + 대기
|
||||
showPairingHint = true
|
||||
var waited = 0
|
||||
while (!bleManager.isBonded && waited < 30000) {
|
||||
kotlinx.coroutines.delay(500)
|
||||
waited += 500
|
||||
}
|
||||
showPairingHint = false
|
||||
if (bleManager.isBonded) {
|
||||
appState.deviceConnected()
|
||||
} else {
|
||||
bleManager.disconnect()
|
||||
bleManager.connectionError.value = context.getString(R.string.device_scan_pairing_required_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(connectionError) {
|
||||
if (connectionError != null) connectingDeviceAddress = null
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) { onDispose { bleManager.stopScan() } }
|
||||
|
||||
// 페어링 안내 다이얼로그
|
||||
if (showPairingHint) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text(stringResource(R.string.device_scan_pairing_required_title), fontWeight = FontWeight.Bold) },
|
||||
text = { Text(stringResource(R.string.device_scan_pairing_required_msg)) },
|
||||
confirmButton = {}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MlBackground)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { appState.backToSensorSelect() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.device_scan_back_desc), tint = MlPrimary)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(stringResource(R.string.device_scan_title), fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
if (knownDevices.isEmpty()) stringResource(R.string.device_scan_searching)
|
||||
else stringResource(R.string.device_scan_tap_or_scan),
|
||||
fontSize = 14.sp, color = MlSecondaryText
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Bluetooth off warning
|
||||
if (!bluetoothEnabled) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color(0xFFFF9500))
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Warning, stringResource(R.string.device_scan_warning_desc), tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(stringResource(R.string.device_scan_bluetooth_off), fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
|
||||
Text("Enable Bluetooth in Settings", color = Color.White.copy(alpha = 0.8f), fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Permission denied warning
|
||||
if (!permissionsGranted) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color(0xFFFF9500))
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Warning, stringResource(R.string.device_scan_warning_desc), tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(stringResource(R.string.device_scan_permissions_required), fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
|
||||
Text("Bluetooth and Location permissions are needed to scan for devices", color = Color.White.copy(alpha = 0.8f), fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Connection error
|
||||
connectionError?.let { error ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Red)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.device_scan_error_desc), tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(error, color = Color.White, fontSize = 13.sp, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = { bleManager.connectionError.value = null }) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.device_scan_dismiss_desc), tint = Color.White.copy(alpha = 0.8f))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// 디바이스 목록 — Known + New 두 섹션
|
||||
val emptyAll = knownDevices.isEmpty() && newDevices.isEmpty()
|
||||
if (emptyAll && isScanning) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
BladdyIdleRiveView(size = 180.dp)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(stringResource(R.string.device_scan_scanning), color = MlSecondaryText)
|
||||
}
|
||||
} else if (emptyAll) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(stringResource(R.string.device_scan_no_devices), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Card(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
TroubleshootItem("1.", stringResource(R.string.device_scan_troubleshoot_1))
|
||||
TroubleshootItem("2.", stringResource(R.string.device_scan_troubleshoot_2))
|
||||
TroubleshootItem("3.", stringResource(R.string.device_scan_troubleshoot_3))
|
||||
TroubleshootItem("4.", stringResource(R.string.device_scan_troubleshoot_4))
|
||||
}
|
||||
}
|
||||
if (appState.isDevMode) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
appState.isDeviceConnected = true
|
||||
appState.currentScreen = com.medithings.vesiscan.AppScreen.PIEZO_PERSONALIZATION
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.15f))
|
||||
) {
|
||||
Text(stringResource(R.string.device_scan_enter_measurement_dev), color = MlPrimary, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (knownDevices.isNotEmpty()) {
|
||||
item("known_header") {
|
||||
SectionHeader(stringResource(R.string.device_scan_saved_devices), knownDevices.size)
|
||||
}
|
||||
items(knownDevices, key = { "k_${it.address}" }) { known ->
|
||||
KnownDeviceRow(
|
||||
device = known,
|
||||
isConnecting = connectingDeviceAddress == known.address,
|
||||
enabled = connectingDeviceAddress == null,
|
||||
onConnect = {
|
||||
connectingDeviceAddress = known.address
|
||||
bleManager.connectByAddress(known.address, known.name)
|
||||
},
|
||||
onDelete = { pendingDelete = known }
|
||||
)
|
||||
}
|
||||
}
|
||||
if (newDevices.isNotEmpty()) {
|
||||
item("new_header") {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.device_scan_new_devices), newDevices.size)
|
||||
}
|
||||
items(newDevices, key = { "n_${it.address}" }) { device ->
|
||||
DeviceRow(
|
||||
device = device,
|
||||
isConnecting = connectingDeviceAddress == device.address,
|
||||
enabled = connectingDeviceAddress == null,
|
||||
onClick = {
|
||||
connectingDeviceAddress = device.address
|
||||
bleManager.connect(device)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Known Device 확인 다이얼로그
|
||||
pendingDelete?.let { target ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDelete = null },
|
||||
title = { Text(stringResource(R.string.unpair_title)) },
|
||||
text = { Text(stringResource(R.string.unpair_message, target.name)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
bleManager.unbondAndRemoveAddress(target.address)
|
||||
knownDevices = KnownDeviceStore.getAll(context)
|
||||
pendingDelete = null
|
||||
}) { Text(stringResource(R.string.unpair_confirm), color = Color.Red) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Scan button
|
||||
Button(
|
||||
onClick = {
|
||||
if (!permissionsGranted) {
|
||||
permissionLauncher.launch(requiredPermissions)
|
||||
} else if (isScanning) {
|
||||
bleManager.stopScan()
|
||||
} else {
|
||||
bleManager.startScan()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
.height(50.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
!permissionsGranted -> stringResource(R.string.device_scan_grant_permissions)
|
||||
isScanning -> stringResource(R.string.device_scan_stop_scan)
|
||||
else -> stringResource(R.string.device_scan_start_scan)
|
||||
},
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(label: String, count: Int) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("$count", fontSize = 12.sp, color = MlSecondaryText.copy(alpha = 0.6f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnownDeviceRow(
|
||||
device: KnownDevice,
|
||||
isConnecting: Boolean,
|
||||
enabled: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val lastConnectedLabel = remember(device.lastConnectedAt) {
|
||||
if (device.lastConnectedAt <= 0) "—"
|
||||
else SimpleDateFormat("M/d HH:mm", Locale.getDefault()).format(Date(device.lastConnectedAt))
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.04f))
|
||||
.clickable(enabled = enabled, onClick = onConnect),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlPrimary.copy(alpha = 0.06f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MlPrimary.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.BluetoothConnected, null, tint = MlPrimary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(device.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.device_scan_last_label, lastConnectedLabel), fontSize = 11.sp, color = MlSecondaryText)
|
||||
}
|
||||
if (isConnecting) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Delete, stringResource(R.string.device_scan_delete_pairing_desc),
|
||||
tint = Color.Red.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceRow(device: BleDevice, isConnecting: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.04f))
|
||||
.clickable(enabled = enabled, onClick = onClick),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MlPrimary.copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("BLE", color = MlPrimary, fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(device.name, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SignalBars(rssi = device.rssi)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(signalLabelRes(device.rssi)), fontSize = 12.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null, tint = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SignalBars(rssi: Int) {
|
||||
val strength = when {
|
||||
rssi >= -80 -> 4 // Good
|
||||
rssi >= -90 -> 2 // Fair
|
||||
else -> 1 // Weak
|
||||
}
|
||||
val color = when {
|
||||
strength >= 4 -> Color.Green
|
||||
strength == 2 -> Color(0xFFFF9500)
|
||||
else -> Color.Red
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.Bottom) {
|
||||
repeat(4) { i ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
.height((6 + i * 3).dp)
|
||||
.clip(RoundedCornerShape(1.dp))
|
||||
.background(if (i < strength) color else Color.Gray.copy(alpha = 0.3f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun signalLabel(rssi: Int): String = when {
|
||||
rssi >= -80 -> "Good"
|
||||
rssi >= -90 -> "Fair"
|
||||
else -> "Weak"
|
||||
}
|
||||
|
||||
/** UI 용 — Locale 적용된 라벨. signalLabel(rssi) 는 dev/log 용으로 유지. */
|
||||
private fun signalLabelRes(rssi: Int): Int = when {
|
||||
rssi >= -80 -> R.string.device_scan_signal_good
|
||||
rssi >= -90 -> R.string.device_scan_signal_fair
|
||||
else -> R.string.device_scan_signal_weak
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TroubleshootItem(number: String, text: String) {
|
||||
Row(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Text(number, fontSize = 13.sp, color = MlPrimary, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text, fontSize = 13.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.medithings.vesiscan.ui.views.consent
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.medithings.vesiscan.R
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.services.UserStorage
|
||||
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun ConsentView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
val storage = remember { UserStorage.getInstance(context) }
|
||||
|
||||
var consentPersonal by remember { mutableStateOf(false) }
|
||||
var consentMedical by remember { mutableStateOf(false) }
|
||||
var consentData by remember { mutableStateOf(false) }
|
||||
|
||||
val allAgreed = consentPersonal && consentMedical && consentData
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MlBackground)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
BladdyIdleRiveView(
|
||||
size = 100.dp,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.consent_title),
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 32.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.consent_intro),
|
||||
fontSize = 14.sp,
|
||||
color = MlSecondaryText,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 전체 동의
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val newVal = !allAgreed
|
||||
consentPersonal = newVal
|
||||
consentMedical = newVal
|
||||
consentData = newVal
|
||||
}
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = allAgreed,
|
||||
onCheckedChange = {
|
||||
consentPersonal = it
|
||||
consentMedical = it
|
||||
consentData = it
|
||||
},
|
||||
colors = CheckboxDefaults.colors(checkedColor = MlPrimary)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.consent_agree_all), fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MlSecondaryText.copy(alpha = 0.2f))
|
||||
|
||||
ConsentItem(
|
||||
title = stringResource(R.string.consent_personal_title),
|
||||
description = stringResource(R.string.consent_personal_desc),
|
||||
checked = consentPersonal,
|
||||
onCheckedChange = { consentPersonal = it }
|
||||
)
|
||||
|
||||
ConsentItem(
|
||||
title = stringResource(R.string.consent_medical_title),
|
||||
description = stringResource(R.string.consent_medical_desc),
|
||||
checked = consentMedical,
|
||||
onCheckedChange = { consentMedical = it }
|
||||
)
|
||||
|
||||
ConsentItem(
|
||||
title = stringResource(R.string.consent_data_title),
|
||||
description = stringResource(R.string.consent_data_desc),
|
||||
checked = consentData,
|
||||
onCheckedChange = { consentData = it }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// 동의 버튼
|
||||
Button(
|
||||
onClick = {
|
||||
storage.consentPersonalInfo = consentPersonal
|
||||
storage.consentMedicalInfo = consentMedical
|
||||
storage.consentDataCollection = consentData
|
||||
appState.completeConsent()
|
||||
},
|
||||
enabled = allAgreed,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 48.dp)
|
||||
.height(56.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = if (allAgreed) Brush.horizontalGradient(listOf(MlTeal, MlPrimary))
|
||||
else Brush.horizontalGradient(listOf(Color.Gray.copy(alpha = 0.4f), Color.Gray.copy(alpha = 0.4f))),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.consent_continue),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConsentItem(
|
||||
title: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MlCardBackground, RoundedCornerShape(12.dp))
|
||||
.clickable { onCheckedChange(!checked) }
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = CheckboxDefaults.colors(checkedColor = MlPrimary)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Text(
|
||||
description,
|
||||
fontSize = 12.sp,
|
||||
color = MlSecondaryText,
|
||||
modifier = Modifier.padding(start = 48.dp, top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package com.medithings.vesiscan.ui.views.history
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.models.*
|
||||
import com.medithings.vesiscan.services.MeasurementHistoryStore
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun MeasurementHistoryView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
val records = remember { MeasurementHistoryStore.getInstance(context).records }
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MlBackground)
|
||||
) {
|
||||
// Top bar
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { appState.backToMonitoring() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), tint = MlPrimary)
|
||||
}
|
||||
Text(stringResource(R.string.history_title), fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.history_records_count, records.size), fontSize = 13.sp, color = MlSecondaryText)
|
||||
}
|
||||
|
||||
// Tab picker
|
||||
TabRow(selectedTabIndex = selectedTab, modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { Text(stringResource(R.string.history_tab_history), modifier = Modifier.padding(12.dp)) }
|
||||
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { Text(stringResource(R.string.history_tab_trends), modifier = Modifier.padding(12.dp)) }
|
||||
}
|
||||
|
||||
if (records.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(stringResource(R.string.history_no_measurements), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(stringResource(R.string.history_tap_spot_hint), fontSize = 14.sp, color = MlSecondaryText.copy(alpha = 0.7f))
|
||||
}
|
||||
} else if (selectedTab == 0) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(records) { record ->
|
||||
RecordCard(record)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Trends tab
|
||||
val measurements = records.filter { it.eventType == MeasurementEventType.MEASUREMENT }.reversed()
|
||||
val cathEvents = records.filter { it.eventType == MeasurementEventType.CATHETERIZATION }
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Level chart
|
||||
item {
|
||||
InteractiveChartCard(
|
||||
title = stringResource(R.string.history_chart_bladder_level),
|
||||
measurements = measurements,
|
||||
valueExtractor = { it.level.toFloat() },
|
||||
maxValue = BladderLevel.MAX_LEVEL.toFloat(),
|
||||
lineColor = MlPrimary.copy(alpha = 0.8f),
|
||||
pointColorExtractor = { it.urgencyLevel.accentColor },
|
||||
thresholdValue = appState.bladderLevel.catheterizationThreshold.toFloat(),
|
||||
unit = "Lv"
|
||||
)
|
||||
}
|
||||
|
||||
// Volume chart
|
||||
if (measurements.isNotEmpty()) {
|
||||
item {
|
||||
InteractiveChartCard(
|
||||
title = stringResource(R.string.history_chart_estimated_volume),
|
||||
measurements = measurements,
|
||||
valueExtractor = { it.estimatedVolume.toFloat() },
|
||||
maxValue = measurements.maxOf { it.estimatedVolume }.toFloat().coerceAtLeast(1f),
|
||||
lineColor = MlTeal,
|
||||
pointColorExtractor = { MlTeal },
|
||||
unit = "ml"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Catheterization log
|
||||
if (cathEvents.isNotEmpty()) {
|
||||
item {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(stringResource(R.string.history_catheterization_log), fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
cathEvents.forEach { event ->
|
||||
val yesterdayLabel = stringResource(R.string.history_yesterday)
|
||||
Row(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Text(formatDate(event.timestamp, yesterdayLabel), fontSize = 13.sp)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.history_at_level, event.level), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InteractiveChartCard(
|
||||
title: String,
|
||||
measurements: List<MeasurementRecord>,
|
||||
valueExtractor: (MeasurementRecord) -> Float,
|
||||
maxValue: Float,
|
||||
lineColor: Color,
|
||||
pointColorExtractor: (MeasurementRecord) -> Color,
|
||||
thresholdValue: Float? = null,
|
||||
unit: String
|
||||
) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var selectedIndex by remember { mutableIntStateOf(-1) }
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (scale > 1.05f) {
|
||||
Text(
|
||||
String.format("%.0f%%", scale * 100),
|
||||
fontSize = 10.sp,
|
||||
color = MlPrimary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Tooltip
|
||||
if (selectedIndex in measurements.indices) {
|
||||
val record = measurements[selectedIndex]
|
||||
val value = valueExtractor(record)
|
||||
val todayLabel = stringResource(R.string.history_today)
|
||||
val yesterdayLabel = stringResource(R.string.history_yesterday)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MlPrimary.copy(alpha = 0.08f))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
formatDateTime(record.timestamp, todayLabel, yesterdayLabel),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MlPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
"${if (unit == "Lv") value.toInt().toString() else String.format("%.0f", value)} $unit",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MlPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
record.urgencyLevel.rawStringValue,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = record.urgencyLevel.accentColor
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
if (measurements.isEmpty()) {
|
||||
Text(stringResource(R.string.history_no_data), fontSize = 12.sp, color = MlSecondaryText)
|
||||
} else {
|
||||
val density = LocalDensity.current
|
||||
val textPaint = remember {
|
||||
android.graphics.Paint().apply {
|
||||
color = android.graphics.Color.parseColor("#80808C")
|
||||
textSize = with(density) { 9.sp.toPx() }
|
||||
isAntiAlias = true
|
||||
textAlign = android.graphics.Paint.Align.CENTER
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.clipToBounds()
|
||||
.pointerInput(measurements.size) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
val newScale = (scale * zoom).coerceIn(1f, 5f)
|
||||
scale = newScale
|
||||
val totalWidth = size.width * scale
|
||||
val maxOff = (totalWidth - size.width).coerceAtLeast(0f)
|
||||
offsetX = (offsetX + pan.x).coerceIn(-maxOff, 0f)
|
||||
}
|
||||
}
|
||||
.pointerInput(measurements.size, scale, offsetX) {
|
||||
detectTapGestures { tapOffset ->
|
||||
if (measurements.size < 2) {
|
||||
selectedIndex = if (selectedIndex == 0) -1 else 0
|
||||
return@detectTapGestures
|
||||
}
|
||||
val totalWidth = size.width * scale
|
||||
val stepX = totalWidth / (measurements.size - 1).toFloat()
|
||||
val dataX = tapOffset.x - offsetX
|
||||
val idx = (dataX / stepX + 0.5f).toInt().coerceIn(0, measurements.size - 1)
|
||||
selectedIndex = if (selectedIndex == idx) -1 else idx
|
||||
}
|
||||
}
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
if (measurements.size < 2) {
|
||||
val value = valueExtractor(measurements[0])
|
||||
val y = size.height * 0.5f
|
||||
drawCircle(pointColorExtractor(measurements[0]), radius = 8f, center = Offset(size.width / 2, y))
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
val totalWidth = size.width * scale
|
||||
val stepX = totalWidth / (measurements.size - 1).toFloat()
|
||||
val chartHeight = size.height - 24f
|
||||
val topPadding = 10f
|
||||
|
||||
// Threshold line (dashed)
|
||||
thresholdValue?.let { threshold ->
|
||||
val thresholdY = topPadding + chartHeight - (threshold / maxValue * chartHeight)
|
||||
drawLine(
|
||||
Color.Red.copy(alpha = 0.4f),
|
||||
Offset(0f, thresholdY),
|
||||
Offset(size.width, thresholdY),
|
||||
strokeWidth = 2f,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 6f))
|
||||
)
|
||||
}
|
||||
|
||||
// Draw line path
|
||||
val path = Path()
|
||||
measurements.forEachIndexed { i, r ->
|
||||
val x = i * stepX + offsetX
|
||||
val value = valueExtractor(r)
|
||||
val y = topPadding + chartHeight - (value / maxValue * chartHeight)
|
||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
}
|
||||
drawPath(path, color = lineColor, style = Stroke(width = 3f))
|
||||
|
||||
// Draw points and labels
|
||||
val labelInterval = when {
|
||||
measurements.size <= 6 -> 1
|
||||
measurements.size <= 15 -> 2
|
||||
measurements.size <= 30 -> 5
|
||||
else -> 10
|
||||
}
|
||||
|
||||
measurements.forEachIndexed { i, r ->
|
||||
val x = i * stepX + offsetX
|
||||
if (x < -20f || x > size.width + 20f) return@forEachIndexed
|
||||
val value = valueExtractor(r)
|
||||
val y = topPadding + chartHeight - (value / maxValue * chartHeight)
|
||||
val isSelected = i == selectedIndex
|
||||
val pointColor = pointColorExtractor(r)
|
||||
|
||||
if (isSelected) {
|
||||
// Vertical guide
|
||||
drawLine(
|
||||
pointColor.copy(alpha = 0.3f),
|
||||
Offset(x, topPadding),
|
||||
Offset(x, topPadding + chartHeight),
|
||||
strokeWidth = 1f,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 4f))
|
||||
)
|
||||
// Selection ring
|
||||
drawCircle(pointColor.copy(alpha = 0.2f), radius = 16f, center = Offset(x, y))
|
||||
drawCircle(pointColor, radius = 8f, center = Offset(x, y))
|
||||
drawCircle(Color.White, radius = 4f, center = Offset(x, y))
|
||||
} else {
|
||||
drawCircle(pointColor, radius = 5f, center = Offset(x, y))
|
||||
}
|
||||
|
||||
// Time labels
|
||||
if (i % labelInterval == 0 || i == measurements.size - 1) {
|
||||
val label = formatShortTime(r.timestamp)
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
label, x, size.height, textPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hint text
|
||||
if (measurements.size > 3 && scale < 1.05f && selectedIndex < 0) {
|
||||
Text(
|
||||
stringResource(R.string.history_pinch_hint),
|
||||
fontSize = 10.sp,
|
||||
color = MlSecondaryText.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordCard(record: MeasurementRecord) {
|
||||
val isCath = record.eventType == MeasurementEventType.CATHETERIZATION
|
||||
Card(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||
modifier = Modifier.fillMaxWidth().shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.04f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isCath) MlPrimary.copy(alpha = 0.12f)
|
||||
else record.urgencyLevel.accentColor.copy(alpha = 0.12f)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
if (isCath) "C" else "M",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (isCath) MlPrimary else record.urgencyLevel.accentColor
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
if (isCath) stringResource(R.string.history_catheterization) else stringResource(R.string.history_measurement),
|
||||
fontSize = 14.sp, fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(formatDate(record.timestamp, stringResource(R.string.history_yesterday)), fontSize = 12.sp, color = MlSecondaryText)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Lv.${record.level}", fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(record.urgencyLevel.accentColor))
|
||||
}
|
||||
Text("${record.estimatedVolume} ml", fontSize = 12.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(timestamp: Long, yesterdayLabel: String = "Yesterday"): String {
|
||||
val cal = Calendar.getInstance()
|
||||
val now = Calendar.getInstance()
|
||||
cal.timeInMillis = timestamp
|
||||
|
||||
return when {
|
||||
cal.get(Calendar.DAY_OF_YEAR) == now.get(Calendar.DAY_OF_YEAR) &&
|
||||
cal.get(Calendar.YEAR) == now.get(Calendar.YEAR) -> {
|
||||
SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(timestamp))
|
||||
}
|
||||
now.timeInMillis - timestamp < 2 * 24 * 60 * 60 * 1000 -> {
|
||||
"$yesterdayLabel " + SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(timestamp))
|
||||
}
|
||||
else -> {
|
||||
SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()).format(Date(timestamp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDateTime(timestamp: Long, todayLabel: String = "Today", yesterdayLabel: String = "Yesterday"): String {
|
||||
val cal = Calendar.getInstance()
|
||||
val now = Calendar.getInstance()
|
||||
cal.timeInMillis = timestamp
|
||||
|
||||
return when {
|
||||
cal.get(Calendar.DAY_OF_YEAR) == now.get(Calendar.DAY_OF_YEAR) &&
|
||||
cal.get(Calendar.YEAR) == now.get(Calendar.YEAR) -> {
|
||||
"$todayLabel " + SimpleDateFormat("h:mm:ss a", Locale.getDefault()).format(Date(timestamp))
|
||||
}
|
||||
now.timeInMillis - timestamp < 2 * 24 * 60 * 60 * 1000 -> {
|
||||
"$yesterdayLabel " + SimpleDateFormat("h:mm:ss a", Locale.getDefault()).format(Date(timestamp))
|
||||
}
|
||||
else -> {
|
||||
SimpleDateFormat("MMM d, h:mm:ss a", Locale.getDefault()).format(Date(timestamp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatShortTime(timestamp: Long): String {
|
||||
return SimpleDateFormat("HH:mm", Locale.US).format(Date(timestamp))
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.medithings.vesiscan.ui.views.home
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.unit.min
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.medithings.vesiscan.R
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun HomeView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
var tapCount by remember { mutableIntStateOf(0) }
|
||||
var lastTapTime by remember { mutableLongStateOf(0L) }
|
||||
var showLanguageDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(MlBackground)) {
|
||||
// 우상단 — 언어 설정 진입 아이콘
|
||||
IconButton(
|
||||
onClick = { showLanguageDialog = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Language, contentDescription = stringResource(R.string.home_language_icon_desc),
|
||||
tint = MlSecondaryText.copy(alpha = 0.7f))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Bladdy hero — 3× 확대(660dp 목표), 화면 작을 땐 가용 영역의 90%까지 캡
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val target = 660.dp
|
||||
val cap = min(maxWidth, maxHeight) * 0.90f
|
||||
val bladdySize = if (target < cap) target else cap
|
||||
BladdyIdleRiveView(size = bladdySize)
|
||||
// 투명 오버레이 — Rive AndroidView가 터치를 가로채므로 위에 덮어씀
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(bladdySize)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastTapTime > 1500) tapCount = 0
|
||||
lastTapTime = now
|
||||
tapCount++
|
||||
if (tapCount >= 3) {
|
||||
tapCount = 0
|
||||
appState.isDevMode = !appState.isDevMode
|
||||
val msg = if (appState.isDevMode) context.getString(R.string.home_dev_mode_on)
|
||||
else context.getString(R.string.home_dev_mode_off)
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text("VesiScan-Basic", fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(stringResource(R.string.home_subtitle), fontSize = 16.sp, color = MlSecondaryText)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
val versionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
Text(versionName ?: "", fontSize = 12.sp, color = MlSecondaryText.copy(alpha = 0.5f))
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// BLE 연결 상태 chip — 연결 유지 정책에서 사용자가 인지 가능하도록
|
||||
if (appState.isDeviceConnected) {
|
||||
Text(
|
||||
stringResource(R.string.home_connected_resume),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF4CAF50),
|
||||
modifier = Modifier
|
||||
.background(Color(0xFF4CAF50).copy(alpha = 0.12f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Button(
|
||||
onClick = { appState.startFromHome() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 12.dp)
|
||||
.height(60.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(R.string.home_start), fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
// Dev 모드 한정 — Clinical R&D Mode 진입
|
||||
if (appState.isDevMode) {
|
||||
Button(
|
||||
onClick = { appState.currentScreen = com.medithings.vesiscan.AppScreen.CLINICAL_HOME },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 48.dp)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C3AED))
|
||||
) {
|
||||
Text(stringResource(R.string.home_clinical_mode_dev), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
}
|
||||
}
|
||||
} // Box (top-bar gear overlay) close
|
||||
|
||||
// 언어 선택 다이얼로그 — 시스템 / 한국어 / English. 변경 시 Activity recreate.
|
||||
if (showLanguageDialog) {
|
||||
val curMode = com.medithings.vesiscan.services.LocalePref.getMode(context)
|
||||
var selected by remember { mutableStateOf(curMode) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLanguageDialog = false },
|
||||
title = { Text(stringResource(R.string.language_dialog_title)) },
|
||||
text = {
|
||||
Column {
|
||||
listOf(
|
||||
com.medithings.vesiscan.services.LocalePref.Mode.SYSTEM to stringResource(R.string.language_system_default),
|
||||
com.medithings.vesiscan.services.LocalePref.Mode.KO to stringResource(R.string.language_korean),
|
||||
com.medithings.vesiscan.services.LocalePref.Mode.EN to stringResource(R.string.language_english),
|
||||
).forEach { (mode, label) ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { selected = mode }
|
||||
.padding(vertical = 6.dp)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected == mode,
|
||||
onClick = { selected = mode }
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
com.medithings.vesiscan.services.LocalePref.setMode(context, selected)
|
||||
showLanguageDialog = false
|
||||
// Activity recreate → attachBaseContext 다시 호출 → 새 locale 적용
|
||||
(context as? android.app.Activity)?.recreate()
|
||||
}) { Text(stringResource(R.string.confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLanguageDialog = false }) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.medithings.vesiscan.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.res.stringResource
|
||||
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.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.measure.VoidingRecord
|
||||
import com.medithings.vesiscan.measure.VoidingRecordStore
|
||||
import com.medithings.vesiscan.speech.SpeechRecognizerManager
|
||||
import com.medithings.vesiscan.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, context.getString(R.string.registered_diary), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
fun startVoice() {
|
||||
if (!hasAudioPerm) { audioPermLauncher.launch(android.Manifest.permission.RECORD_AUDIO); return }
|
||||
speechManager?.destroy()
|
||||
isListening = true; voiceStatus = context.getString(R.string.listening)
|
||||
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 = context.getString(R.string.recognition_fail, candidates.firstOrNull() ?: "") }
|
||||
},
|
||||
onError = { code -> isListening = false; voiceStatus = when(code) {
|
||||
android.speech.SpeechRecognizer.ERROR_NO_MATCH -> context.getString(R.string.recognition_fail, "")
|
||||
android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> context.getString(R.string.error_code, code)
|
||||
else -> context.getString(R.string.error_code, code)
|
||||
}},
|
||||
onReady = { voiceStatus = context.getString(R.string.speak_now) }
|
||||
)
|
||||
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(stringResource(R.string.registered_diary), 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(stringResource(R.string.confirm)) }
|
||||
} else {
|
||||
// 입력 모드 선택
|
||||
Text(stringResource(R.string.voiding_record), fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// 3 탭 버튼
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// 수기
|
||||
Button(
|
||||
onClick = { selectedTab = 0 },
|
||||
modifier = Modifier.weight(1f).height(64.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (selectedTab == 0) Color(0xFFFF9800) else Color(0xFFFF9800).copy(alpha = 0.1f)
|
||||
)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.Edit, null, Modifier.size(20.dp), tint = if (selectedTab == 0) Color.White else Color(0xFFFF9800))
|
||||
Text(stringResource(R.string.manual_input), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = if (selectedTab == 0) Color.White else Color(0xFFFF9800))
|
||||
}
|
||||
}
|
||||
// 음성
|
||||
Button(
|
||||
onClick = { selectedTab = 1 },
|
||||
modifier = Modifier.weight(1f).height(64.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (selectedTab == 1) Color(0xFF007AFF) else Color(0xFF007AFF).copy(alpha = 0.1f)
|
||||
)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.SettingsVoice, null, Modifier.size(20.dp), tint = if (selectedTab == 1) Color.White else Color(0xFF007AFF))
|
||||
Text(stringResource(R.string.voice_input), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = if (selectedTab == 1) Color.White else Color(0xFF007AFF))
|
||||
}
|
||||
}
|
||||
// 카메라
|
||||
Button(
|
||||
onClick = { onCameraRequested(); onDismiss() },
|
||||
modifier = Modifier.weight(1f).height(64.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50).copy(alpha = 0.1f))
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.CameraAlt, null, Modifier.size(20.dp), tint = Color(0xFF4CAF50))
|
||||
Text(stringResource(R.string.camera_input), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFF4CAF50))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(stringResource(R.string.volume_placeholder), 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(stringResource(R.string.save_to_diary), 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) stringResource(R.string.listening) else stringResource(R.string.tap_to_speak),
|
||||
color = Color.Gray, fontSize = 14.sp
|
||||
)
|
||||
if (voiceStatus.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(voiceStatus, color = Color.Gray, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2871
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
package com.medithings.vesiscan.ui.views.monitoring
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Matrix
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.measure.SimpleMeasureService
|
||||
import com.medithings.vesiscan.measure.VoidingRecord
|
||||
import com.medithings.vesiscan.measure.VoidingRecordStore
|
||||
import com.medithings.vesiscan.measure.YoloDetector
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* 소변컵 카메라 촬영 + V2 측정 화면 (medilightv2용 간소화 버전)
|
||||
* YOLO 자동감지 → 2초 자동촬영 → V2 측정 → 로컬 저장
|
||||
*/
|
||||
@Composable
|
||||
fun UrineCameraScreen(
|
||||
onDone: (Int?) -> Unit, // 측정 완료 콜백 (volume)
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var hasCameraPerm by remember {
|
||||
mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
||||
}
|
||||
val permLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { hasCameraPerm = it }
|
||||
LaunchedEffect(Unit) { if (!hasCameraPerm) permLauncher.launch(Manifest.permission.CAMERA) }
|
||||
|
||||
val yoloDetector = remember { YoloDetector(context) }
|
||||
var isModelLoaded by remember { mutableStateOf(false) }
|
||||
var currentDetection by remember { mutableStateOf<YoloDetector.DetectionResult?>(null) }
|
||||
LaunchedEffect(Unit) { withContext(Dispatchers.IO) { isModelLoaded = yoloDetector.loadModel() } }
|
||||
DisposableEffect(Unit) { onDispose { yoloDetector.close() } }
|
||||
|
||||
var capturedBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var isAnalyzing by remember { mutableStateOf(false) }
|
||||
var resultVolume by remember { mutableStateOf<Int?>(null) }
|
||||
var analysisComplete by remember { mutableStateOf(false) }
|
||||
|
||||
// 자동 촬영
|
||||
var detectionStartTime by remember { mutableStateOf<Long?>(null) }
|
||||
var autoCaptureProgress by remember { mutableFloatStateOf(0f) }
|
||||
var isAutoCapturing by remember { mutableStateOf(false) }
|
||||
val autoCaptureDelay = 2000L
|
||||
|
||||
val imageCapture = remember {
|
||||
ImageCapture.Builder().setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY).build()
|
||||
}
|
||||
|
||||
fun doCapture() {
|
||||
isAutoCapturing = true
|
||||
imageCapture.takePicture(ContextCompat.getMainExecutor(context), object : ImageCapture.OnImageCapturedCallback() {
|
||||
override fun onCaptureSuccess(image: ImageProxy) {
|
||||
val bmp = image.toBitmap()
|
||||
val rotation = image.imageInfo.rotationDegrees.toFloat()
|
||||
image.close()
|
||||
val rotated = if (rotation != 0f) {
|
||||
val m = Matrix().apply { postRotate(rotation) }
|
||||
Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, m, true)
|
||||
} else bmp
|
||||
originalBitmap = rotated
|
||||
val det = currentDetection
|
||||
if (det != null) {
|
||||
val box = det.boundingBox
|
||||
val padH = 0.10f; val padTop = 0.10f; val padBot = 0.05f
|
||||
val cropX = ((box.left - box.width() * padH).coerceAtLeast(0f) * rotated.width).toInt()
|
||||
val cropY = ((box.top - box.height() * padTop).coerceAtLeast(0f) * rotated.height).toInt()
|
||||
val cropR = ((box.right + box.width() * padH).coerceAtMost(1f) * rotated.width).toInt()
|
||||
val cropB = ((box.bottom + box.height() * padBot).coerceAtMost(1f) * rotated.height).toInt()
|
||||
val cropW = (cropR - cropX).coerceAtMost(rotated.width - cropX)
|
||||
val cropH = (cropB - cropY).coerceAtMost(rotated.height - cropY)
|
||||
capturedBitmap = if (cropW > 50 && cropH > 50) Bitmap.createBitmap(rotated, cropX, cropY, cropW, cropH) else rotated
|
||||
} else {
|
||||
capturedBitmap = rotated
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 자동 촬영 로직
|
||||
LaunchedEffect(currentDetection, isAutoCapturing, capturedBitmap) {
|
||||
if (capturedBitmap != null || isAutoCapturing) return@LaunchedEffect
|
||||
if (currentDetection != null && currentDetection!!.confidence >= 0.90f) {
|
||||
if (detectionStartTime == null) detectionStartTime = System.currentTimeMillis()
|
||||
while (currentDetection != null && currentDetection!!.confidence >= 0.90f && !isAutoCapturing && capturedBitmap == null) {
|
||||
val elapsed = System.currentTimeMillis() - (detectionStartTime ?: System.currentTimeMillis())
|
||||
autoCaptureProgress = (elapsed.toFloat() / autoCaptureDelay).coerceIn(0f, 1f)
|
||||
if (elapsed >= autoCaptureDelay) { doCapture(); break }
|
||||
delay(50)
|
||||
}
|
||||
} else { detectionStartTime = null; autoCaptureProgress = 0f }
|
||||
}
|
||||
|
||||
// 자동 분석
|
||||
LaunchedEffect(capturedBitmap) {
|
||||
val bmp = capturedBitmap ?: return@LaunchedEffect
|
||||
isAnalyzing = true
|
||||
try {
|
||||
val result = SimpleMeasureService.measure(bmp)
|
||||
resultVolume = result.volume
|
||||
} catch (_: Exception) { resultVolume = null }
|
||||
isAnalyzing = false
|
||||
analysisComplete = true
|
||||
}
|
||||
|
||||
val borderColor by animateColorAsState(
|
||||
if (currentDetection != null && currentDetection!!.confidence >= 0.90f) Color.Green else Color.Transparent,
|
||||
tween(300), label = "border"
|
||||
)
|
||||
|
||||
if (!hasCameraPerm) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(stringResource(R.string.camera_permission_needed))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = { permLauncher.launch(Manifest.permission.CAMERA) }) { Text(stringResource(R.string.grant_permission)) }
|
||||
}
|
||||
}
|
||||
} else if (capturedBitmap != null) {
|
||||
// === 분석 결과 화면 ===
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(Color.White).padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) {
|
||||
IconButton(onClick = onCancel) { Icon(Icons.Default.ArrowBack, stringResource(R.string.back)) }
|
||||
Text(stringResource(R.string.urine_measurement), fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.size(48.dp))
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Image(bitmap = capturedBitmap!!.asImageBitmap(), contentDescription = null, modifier = Modifier.fillMaxWidth().weight(1f))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (isAnalyzing) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.analyzing), color = Color.Gray)
|
||||
}
|
||||
} else if (analysisComplete) {
|
||||
if (resultVolume != null && resultVolume!! > 0) {
|
||||
Text(stringResource(R.string.measurement_result), color = Color.Gray, fontSize = 14.sp)
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text("$resultVolume", fontSize = 48.sp, fontWeight = FontWeight.Bold, color = Color(0xFF007AFF))
|
||||
Text(" ml", fontSize = 20.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 8.dp))
|
||||
}
|
||||
} else {
|
||||
Text(stringResource(R.string.measurement_fail), fontSize = 20.sp, color = Color.Red)
|
||||
Text(stringResource(R.string.retry_in_bright), color = Color.Gray, fontSize = 14.sp)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { capturedBitmap = null; originalBitmap = null; analysisComplete = false; resultVolume = null; isAutoCapturing = false; detectionStartTime = null; autoCaptureProgress = 0f },
|
||||
Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp)
|
||||
) { Text(stringResource(R.string.retake_photo)) }
|
||||
Button(
|
||||
onClick = {
|
||||
val vol = resultVolume
|
||||
if (vol != null && vol > 0) {
|
||||
VoidingRecordStore.add(context, VoidingRecord(volume = vol, method = "camera"))
|
||||
Toast.makeText(context, context.getString(R.string.registered_diary), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
onDone(vol)
|
||||
},
|
||||
Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50))
|
||||
) { Text(stringResource(R.string.save_to_diary), fontWeight = FontWeight.SemiBold) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
} else {
|
||||
// === 카메라 프리뷰 ===
|
||||
Box(
|
||||
Modifier.fillMaxSize().border(
|
||||
width = if (borderColor == Color.Transparent) 0.dp else 4.dp, color = borderColor
|
||||
)
|
||||
) {
|
||||
val previewView = remember { PreviewView(context) }
|
||||
val executor = remember { Executors.newSingleThreadExecutor() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val future = ProcessCameraProvider.getInstance(context)
|
||||
future.addListener({
|
||||
val provider = future.get()
|
||||
val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) }
|
||||
val analysis = ImageAnalysis.Builder().setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build().also { a ->
|
||||
a.setAnalyzer(executor) { proxy ->
|
||||
if (isModelLoaded) {
|
||||
val bmp = proxy.toBitmap()
|
||||
val rot = proxy.imageInfo.rotationDegrees.toFloat()
|
||||
val rotated = if (rot != 0f) { val m = Matrix().apply { postRotate(rot) }; Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, m, true) } else bmp
|
||||
currentDetection = yoloDetector.detect(rotated)
|
||||
}
|
||||
proxy.close()
|
||||
}
|
||||
}
|
||||
try { provider.unbindAll(); provider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture, analysis) } catch (_: Exception) {}
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
}
|
||||
|
||||
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
|
||||
|
||||
// 상단
|
||||
Row(Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onCancel) { Icon(Icons.Default.Close, stringResource(R.string.close), tint = Color.White) }
|
||||
Spacer(Modifier.weight(1f))
|
||||
val statusText = when {
|
||||
!isModelLoaded -> stringResource(R.string.model_loading)
|
||||
isAutoCapturing -> stringResource(R.string.auto_capturing)
|
||||
autoCaptureProgress > 0 -> stringResource(R.string.hold_still, (autoCaptureProgress * 100).toInt())
|
||||
currentDetection != null -> stringResource(R.string.cup_detected)
|
||||
else -> stringResource(R.string.searching_cup)
|
||||
}
|
||||
Text(statusText, color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(20.dp)).padding(horizontal = 12.dp, vertical = 6.dp))
|
||||
Spacer(Modifier.weight(1f)); Spacer(Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
// 가이드
|
||||
Column(Modifier.align(Alignment.Center).offset(y = 120.dp).background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(10.dp)).padding(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(stringResource(R.string.camera_guide_1), color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.camera_guide_2), color = Color(0xFFFFD54F), fontSize = 12.sp)
|
||||
if (autoCaptureProgress > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(progress = { autoCaptureProgress }, Modifier.fillMaxWidth(0.6f).height(4.dp).clip(RoundedCornerShape(2.dp)), color = Color.Green, trackColor = Color.White.copy(alpha = 0.3f))
|
||||
}
|
||||
}
|
||||
|
||||
// 수동 촬영 버튼
|
||||
Box(Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp)) {
|
||||
Button(onClick = { if (!isAutoCapturing) doCapture() }, Modifier.size(80.dp), shape = CircleShape,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.White), contentPadding = PaddingValues(0.dp)
|
||||
) { Box(Modifier.size(64.dp).background(Color.White, CircleShape)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+497
@@ -0,0 +1,497 @@
|
||||
package com.medithings.vesiscan.ui.views.monitoring
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.managers.NirsManager
|
||||
import com.medithings.vesiscan.models.NirsMcjPacket
|
||||
import com.medithings.vesiscan.models.NirsOxyResult
|
||||
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
import kotlin.random.Random
|
||||
|
||||
@Composable
|
||||
fun VivamyoMonitoringView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
val bleManager = remember { BleManager.getInstance(context) }
|
||||
val nirsManager = remember { NirsManager.instance }
|
||||
val handler = remember { Handler(Looper.getMainLooper()) }
|
||||
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp
|
||||
val isCompact = screenWidth < 400
|
||||
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
var showClearConfirm by remember { mutableStateOf(false) }
|
||||
var isToggling by remember { mutableStateOf(false) }
|
||||
|
||||
val batteryLvl by bleManager.batteryLevel
|
||||
val isConnected by bleManager.isConnected
|
||||
val isStreaming by nirsManager.isStreaming
|
||||
val currentOxy by nirsManager.currentOxy
|
||||
val sampleCount by nirsManager.sampleCount
|
||||
val isFilterEnabled by nirsManager.isFilterEnabled
|
||||
|
||||
val isDemoMode = appState.isDemoMode
|
||||
var demoRunnable by remember { mutableStateOf<Runnable?>(null) }
|
||||
var demoBaseHbO2 by remember { mutableStateOf(12.0) }
|
||||
var demoBaseHb by remember { mutableStateOf(8.0) }
|
||||
|
||||
var mcjRetryCount = remember { mutableIntStateOf(0) }
|
||||
var mcjRetryRunnable by remember { mutableStateOf<Runnable?>(null) }
|
||||
|
||||
fun cancelMcjRetry() {
|
||||
mcjRetryRunnable?.let { handler.removeCallbacks(it) }
|
||||
mcjRetryRunnable = null
|
||||
mcjRetryCount.intValue = 0
|
||||
}
|
||||
|
||||
fun startNirsStream() {
|
||||
cancelMcjRetry()
|
||||
|
||||
bleManager.onNirsMcjReceived = { data ->
|
||||
cancelMcjRetry() // Got response, cancel retry
|
||||
if (nirsManager.isStreaming.value) {
|
||||
val packet = NirsMcjPacket.parse(data)
|
||||
if (packet != null) {
|
||||
nirsManager.processMcjPacket(packet)
|
||||
} else {
|
||||
if (com.medithings.vesiscan.BuildConfig.DEBUG) {
|
||||
android.util.Log.e("VivamyoMonitor", "NirsMcjPacket.parse returned null! data.size=${data.size}")
|
||||
}
|
||||
}
|
||||
handler.postDelayed({
|
||||
if (nirsManager.isStreaming.value) {
|
||||
bleManager.sendMcjQuery()
|
||||
// Retry if no response within 2s
|
||||
val retry = Runnable {
|
||||
if (nirsManager.isStreaming.value && mcjRetryCount.intValue < 3) {
|
||||
mcjRetryCount.intValue++
|
||||
bleManager.sendMcjQuery()
|
||||
}
|
||||
}
|
||||
mcjRetryRunnable = retry
|
||||
handler.postDelayed(retry, 2000)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Start mcj chain directly (same as iOS)
|
||||
bleManager.sendMcjQuery()
|
||||
|
||||
// Retry if first mcj gets no response within 2s
|
||||
val initialRetry = Runnable {
|
||||
if (nirsManager.isStreaming.value && mcjRetryCount.intValue < 5) {
|
||||
mcjRetryCount.intValue++
|
||||
bleManager.sendMcjQuery()
|
||||
}
|
||||
}
|
||||
mcjRetryRunnable = initialRetry
|
||||
handler.postDelayed(initialRetry, 2000)
|
||||
}
|
||||
|
||||
fun startDemoStream() {
|
||||
val tick = object : Runnable {
|
||||
override fun run() {
|
||||
if (!nirsManager.isStreaming.value) return
|
||||
demoBaseHbO2 += Random.nextDouble(-0.3, 0.3)
|
||||
demoBaseHb += Random.nextDouble(-0.2, 0.2)
|
||||
val hbO2 = demoBaseHbO2.coerceIn(5.0, 25.0)
|
||||
val hb = demoBaseHb.coerceIn(3.0, 15.0)
|
||||
demoBaseHbO2 = hbO2
|
||||
demoBaseHb = hb
|
||||
val tHb = hbO2 + hb
|
||||
val stO2 = if (tHb > 0) (hbO2 / tHb * 100.0) else 0.0
|
||||
|
||||
nirsManager.currentOxy.value = NirsOxyResult(hbO2, hb, tHb, stO2)
|
||||
nirsManager.sampleCount.intValue++
|
||||
nirsManager.oxyHbO2History.add(hbO2)
|
||||
nirsManager.oxyHbHistory.add(hb)
|
||||
if (nirsManager.oxyHbO2History.size > 240) nirsManager.oxyHbO2History.removeAt(0)
|
||||
if (nirsManager.oxyHbHistory.size > 240) nirsManager.oxyHbHistory.removeAt(0)
|
||||
|
||||
handler.postDelayed(this, 250)
|
||||
}
|
||||
}
|
||||
demoRunnable = tick
|
||||
handler.post(tick)
|
||||
}
|
||||
|
||||
fun stopDemoStream() {
|
||||
demoRunnable?.let { handler.removeCallbacks(it) }
|
||||
demoRunnable = null
|
||||
nirsManager.stopStreaming()
|
||||
}
|
||||
|
||||
fun stopNirsStream() {
|
||||
// 1. Stop polling chain & clear callback first
|
||||
nirsManager.stopStreaming()
|
||||
cancelMcjRetry()
|
||||
bleManager.onNirsMcjReceived = null
|
||||
|
||||
// 2. Resume battery polling after in-flight mcj response has time to arrive
|
||||
handler.postDelayed({
|
||||
bleManager.startBatteryPolling()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
fun toggleStreaming() {
|
||||
if (isToggling) return
|
||||
isToggling = true
|
||||
if (isStreaming) {
|
||||
if (isDemoMode) stopDemoStream() else stopNirsStream()
|
||||
} else {
|
||||
if (isDemoMode) {
|
||||
nirsManager.startStreaming()
|
||||
startDemoStream()
|
||||
} else {
|
||||
bleManager.stopBatteryPolling()
|
||||
nirsManager.startStreaming()
|
||||
startNirsStream()
|
||||
}
|
||||
}
|
||||
handler.postDelayed({ isToggling = false }, 1000)
|
||||
}
|
||||
|
||||
// BLE 끊김 banner는 사내 테스트 피드백으로 제거됨 — BleManager 측 자동 재연결만 동작.
|
||||
LaunchedEffect(Unit) {
|
||||
bleManager.onUnexpectedDisconnect = { /* no-op: silent reconnect */ }
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) { onDispose {
|
||||
if (isDemoMode) { stopDemoStream() }
|
||||
else if (nirsManager.isStreaming.value) stopNirsStream()
|
||||
else { cancelMcjRetry(); bleManager.onNirsMcjReceived = null }
|
||||
} }
|
||||
|
||||
// Clear confirm dialog
|
||||
if (showClearConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearConfirm = false },
|
||||
title = { Text(stringResource(R.string.vivamyo_clear_title)) },
|
||||
text = { Text(stringResource(R.string.vivamyo_clear_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
nirsManager.clearData()
|
||||
showClearConfirm = false
|
||||
}) { Text(stringResource(R.string.vivamyo_clear_button), color = Color.Red) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearConfirm = false }) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val stO2Color = when {
|
||||
currentOxy.stO2 < 50 -> Color.Red
|
||||
currentOxy.stO2 < 60 -> Color(0xFFFF9500)
|
||||
else -> Color.Green
|
||||
}
|
||||
val stO2StatusText = when {
|
||||
currentOxy.stO2 < 50 -> context.getString(R.string.vivamyo_status_low)
|
||||
currentOxy.stO2 < 60 -> context.getString(R.string.vivamyo_status_caution)
|
||||
currentOxy.stO2 <= 85 -> context.getString(R.string.vivamyo_status_normal)
|
||||
else -> context.getString(R.string.vivamyo_status_check)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White)
|
||||
) {
|
||||
// Top bar
|
||||
val hPad = if (isCompact) 12.dp else 24.dp
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
nirsManager.reset()
|
||||
appState.goHome()
|
||||
}, modifier = Modifier.size(48.dp)) {
|
||||
Icon(Icons.Default.Home, stringResource(R.string.vivamyo_home_desc), tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = if (isCompact) 4.dp else 8.dp)) {
|
||||
Text(stringResource(R.string.vivamyo_title), fontSize = if (isCompact) 16.sp else 20.sp, fontWeight = FontWeight.Bold)
|
||||
if (!isCompact) Text(stringResource(R.string.vivamyo_subtitle), fontSize = 13.sp, color = MlSecondaryText)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (isDemoMode) {
|
||||
Text("DEMO", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White,
|
||||
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(Color(0xFFFF9500)).padding(horizontal = 4.dp, vertical = 2.dp))
|
||||
} else {
|
||||
Text("$batteryLvl%", fontSize = if (isCompact) 11.sp else 13.sp, color = MlSecondaryText)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(if (isCompact) 4.dp else 8.dp))
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(if (isDemoMode || isConnected) Color.Green else Color.Red))
|
||||
}
|
||||
|
||||
// Tab picker
|
||||
TabRow(selectedTabIndex = selectedTab, modifier = Modifier.padding(horizontal = hPad)) {
|
||||
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { Text("SpO2", modifier = Modifier.padding(if (isCompact) 8.dp else 12.dp)) }
|
||||
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { Text("Oxy/Deoxy", modifier = Modifier.padding(if (isCompact) 8.dp else 12.dp)) }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(if (isCompact) 10.dp else 16.dp)
|
||||
) {
|
||||
if (selectedTab == 0) {
|
||||
// SpO2 tab
|
||||
BladdyIdleRiveView(
|
||||
size = if (isCompact) 80.dp else 120.dp,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(String.format("%.1f", currentOxy.stO2), fontSize = if (isCompact) 40.sp else 56.sp, fontWeight = FontWeight.Bold, color = stO2Color)
|
||||
Text("% StO2", fontSize = 18.sp, color = MlSecondaryText)
|
||||
Text(stO2StatusText, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = stO2Color,
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(stO2Color.copy(alpha = 0.15f)).padding(horizontal = 12.dp, vertical = 4.dp))
|
||||
}
|
||||
|
||||
NirsMetricsCard(nirsManager)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.vivamyo_samples_label, sampleCount), fontSize = 11.sp, color = MlSecondaryText)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(stringResource(R.string.vivamyo_window_label), fontSize = 11.sp, color = MlSecondaryText)
|
||||
}
|
||||
} else {
|
||||
// Oxy/Deoxy tab
|
||||
LineChartCard("HbO2 (Oxy)", nirsManager.oxyHbO2History, Color.Red, "uM")
|
||||
LineChartCard("HHb (DeOxy)", nirsManager.oxyHbHistory, Color.Blue, "uM")
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.vivamyo_samples_label, sampleCount), fontSize = 11.sp, color = MlSecondaryText)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (isFilterEnabled) {
|
||||
Text(stringResource(R.string.vivamyo_filtered), fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White,
|
||||
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(Color(0xFF9966FF)).padding(horizontal = 6.dp, vertical = 2.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(stringResource(R.string.vivamyo_window_label), fontSize = 11.sp, color = MlSecondaryText)
|
||||
}
|
||||
|
||||
NirsMetricsCard(nirsManager)
|
||||
}
|
||||
}
|
||||
|
||||
// Control bar
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = hPad).padding(bottom = if (isCompact) 16.dp else 32.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = { showClearConfirm = true },
|
||||
modifier = Modifier.weight(1f).height(50.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.1f))
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Delete, null, tint = Color.Red)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.vivamyo_clear), fontWeight = FontWeight.SemiBold, color = Color.Red)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { toggleStreaming() },
|
||||
modifier = Modifier.weight(1f).height(50.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues(),
|
||||
enabled = !isToggling
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||
RoundedCornerShape(14.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
if (isStreaming) Icons.Default.Stop else Icons.Default.PlayArrow,
|
||||
null, tint = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(if (isStreaming) stringResource(R.string.vivamyo_stop) else stringResource(R.string.vivamyo_start), fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NirsMetricsCard(nirsManager: NirsManager) {
|
||||
val oxy by nirsManager.currentOxy
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.04f))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MetricBox("HbO2", oxy.hbO2, "uM", Color.Red, Modifier.weight(1f))
|
||||
MetricBox("HHb", oxy.hb, "uM", Color.Blue, Modifier.weight(1f))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MetricBox("tHb", oxy.tHb, "uM", Color(0xFF9966FF), Modifier.weight(1f))
|
||||
MetricBox("StO2", oxy.stO2, "%", Color.Green, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetricBox(title: String, value: Double, unit: String, color: Color, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MlBackground)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(title, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||
}
|
||||
Text(String.format("%.1f", value), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1C1B1F), modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.End)
|
||||
Text(unit, fontSize = 10.sp, color = MlSecondaryText, modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.End)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LineChartCard(label: String, data: List<Double>, color: Color, unit: String) {
|
||||
val axisColor = MlSecondaryText.copy(alpha = 0.4f)
|
||||
val gridColor = MlSecondaryText.copy(alpha = 0.1f)
|
||||
val sampleRate = 4f // Hz
|
||||
val yLabelWidth = 36.dp
|
||||
val xLabelHeight = 18.dp
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.04f))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.size(6.dp).clip(CircleShape).background(color))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(label, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
String.format("%.1f %s", data.lastOrNull() ?: 0.0, unit),
|
||||
fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = color
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (data.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().height(140.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) { Text(stringResource(R.string.vivamyo_no_data), fontSize = 11.sp, color = MlSecondaryText) }
|
||||
} else {
|
||||
val minVal = data.min()
|
||||
val maxVal = data.max()
|
||||
val range = if (maxVal - minVal > 0.001) maxVal - minVal else 1.0
|
||||
val yMin = minVal - range * 0.05
|
||||
val yMax = maxVal + range * 0.05
|
||||
val yRange = yMax - yMin
|
||||
|
||||
// Y-axis tick values (5 ticks)
|
||||
val yTicks = (0..4).map { yMin + yRange * it / 4.0 }
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().height(140.dp + xLabelHeight)) {
|
||||
// Y-axis labels
|
||||
Column(
|
||||
modifier = Modifier.width(yLabelWidth).height(140.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
yTicks.reversed().forEach { tick ->
|
||||
Text(
|
||||
String.format("%.1f", tick),
|
||||
fontSize = 8.sp,
|
||||
color = MlSecondaryText,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
// Chart area
|
||||
Canvas(modifier = Modifier.fillMaxWidth().height(140.dp)) {
|
||||
// Grid lines
|
||||
for (i in 0..4) {
|
||||
val y = size.height * i / 4f
|
||||
drawLine(gridColor, Offset(0f, y), Offset(size.width, y), strokeWidth = 1f)
|
||||
}
|
||||
|
||||
// Data line
|
||||
if (data.size >= 2) {
|
||||
val stepX = size.width / (data.size - 1).toFloat()
|
||||
val path = Path()
|
||||
data.forEachIndexed { index, value ->
|
||||
val x = index * stepX
|
||||
val y = size.height - ((value - yMin) / yRange * size.height).toFloat()
|
||||
if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
}
|
||||
drawPath(path, color = color, style = Stroke(width = 3f))
|
||||
}
|
||||
}
|
||||
|
||||
// X-axis labels
|
||||
val totalSeconds = data.size / sampleRate
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(xLabelHeight),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(String.format("%.0fs", 0f), fontSize = 8.sp, color = MlSecondaryText)
|
||||
Text(String.format("%.0fs", totalSeconds / 2), fontSize = 8.sp, color = MlSecondaryText)
|
||||
Text(String.format("%.0fs", totalSeconds), fontSize = 8.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unit label
|
||||
Text(unit, fontSize = 8.sp, color = MlSecondaryText, modifier = Modifier.padding(start = yLabelWidth))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.medithings.vesiscan.ui.views.monitoring
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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 com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.measure.VoidingRecord
|
||||
import com.medithings.vesiscan.measure.VoidingRecordStore
|
||||
import com.medithings.vesiscan.ui.theme.MlCardBackground
|
||||
import com.medithings.vesiscan.ui.theme.MlPrimary
|
||||
import com.medithings.vesiscan.ui.theme.MlSecondaryText
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* 배뇨일지 목록 화면 — 로컬 기록 조회 + 의료 지표
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VoidingDiaryView(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var records by remember { mutableStateOf(VoidingRecordStore.getAll(context).sortedByDescending { it.timestamp }) }
|
||||
var showDeleteConfirm by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) }
|
||||
val grouped = remember(records) {
|
||||
records.groupBy { dateFormat.format(Date(it.timestamp)) }
|
||||
.toSortedMap(compareByDescending { it })
|
||||
}
|
||||
|
||||
// 의료 지표 계산 (최근 24시간 기준)
|
||||
val metrics = remember(records) { calculateMedicalMetrics(records) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.voiding_diary), fontWeight = FontWeight.SemiBold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
if (records.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.Description, contentDescription = null, Modifier.size(48.dp), Color.Gray.copy(alpha = 0.5f))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(stringResource(R.string.no_records), color = Color.Gray)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// 요약 바 (iOS summaryBar: 건수 + 총량 + 평균)
|
||||
item { SummaryBar(records) }
|
||||
|
||||
// 의료 지표 카드
|
||||
item { MedicalMetricsCard(metrics) }
|
||||
|
||||
grouped.forEach { (date, dayRecords) ->
|
||||
item {
|
||||
val displayDate = try {
|
||||
val d = dateFormat.parse(date)
|
||||
SimpleDateFormat("M/d (E)", Locale.getDefault()).format(d!!)
|
||||
} catch (_: Exception) { date }
|
||||
|
||||
Text(displayDate, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp))
|
||||
}
|
||||
|
||||
items(dayRecords, key = { it.id }) { record ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.background(Color.Gray.copy(alpha = 0.08f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(record.timeString, fontFamily = FontFamily.Monospace, color = Color.Gray,
|
||||
modifier = Modifier.width(50.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
when {
|
||||
record.volume != null -> {
|
||||
Icon(Icons.Default.WaterDrop, contentDescription = null, Modifier.size(16.dp), Color(0xFF007AFF))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("${record.volume} ml", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
record.activity == "awake" -> {
|
||||
Icon(Icons.Default.WbSunny, contentDescription = null, Modifier.size(16.dp), Color(0xFFFF9800))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.awake))
|
||||
}
|
||||
record.activity == "sleep" -> {
|
||||
Icon(Icons.Default.DarkMode, contentDescription = null, Modifier.size(16.dp), Color(0xFF9C27B0))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.sleep))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
val methodLabel = when (record.method) {
|
||||
"voice" -> stringResource(R.string.method_voice)
|
||||
"camera" -> stringResource(R.string.method_camera)
|
||||
else -> stringResource(R.string.method_manual)
|
||||
}
|
||||
Text(methodLabel, fontSize = 10.sp, color = Color.Gray,
|
||||
modifier = Modifier.background(Color.Gray.copy(alpha = 0.15f), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp))
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = { showDeleteConfirm = record.id },
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete), Modifier.size(20.dp), Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(stringResource(R.string.total_records, records.size), color = Color.Gray, fontSize = 12.sp,
|
||||
modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 삭제 확인
|
||||
showDeleteConfirm?.let { id ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirm = null },
|
||||
title = { Text(stringResource(R.string.delete_record)) },
|
||||
text = { Text(stringResource(R.string.delete_confirm_msg)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
VoidingRecordStore.delete(context, id)
|
||||
records = VoidingRecordStore.getAll(context).sortedByDescending { it.timestamp }
|
||||
showDeleteConfirm = null
|
||||
}) { Text(stringResource(R.string.delete), color = Color.Red) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showDeleteConfirm = null }) { Text(stringResource(R.string.cancel)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class DiaryMetrics(
|
||||
val totalVolume24h: Int, // 24hUV (24-hour urine volume)
|
||||
val nocturnalVolume: Int, // NUV (nocturnal urine volume)
|
||||
val npi: Double, // Nocturnal Polyuria Index (NUV / 24hUV)
|
||||
val maxCapacity: Int, // Max single voiding volume
|
||||
val totalCount: Int, // 총 횟수
|
||||
val daytimeCount: Int, // 주간 횟수
|
||||
val nighttimeCount: Int // 야간 횟수
|
||||
)
|
||||
|
||||
private fun calculateMedicalMetrics(records: List<VoidingRecord>): DiaryMetrics {
|
||||
val now = System.currentTimeMillis()
|
||||
val h24ago = now - 24 * 60 * 60 * 1000L
|
||||
val recent = records.filter { it.timestamp >= h24ago }.sortedBy { it.timestamp }
|
||||
|
||||
val voidingRecords = recent.filter { it.volume != null }
|
||||
|
||||
// awake/sleep 경계 찾기
|
||||
val lastAwake = recent.lastOrNull { it.activity == "awake" }
|
||||
val lastSleep = recent.lastOrNull { it.activity == "sleep" }
|
||||
|
||||
// 야간 = sleep 이후 ~ awake 이전의 기록
|
||||
val nocturnalRecords = if (lastSleep != null && lastAwake != null) {
|
||||
if (lastSleep.timestamp < lastAwake.timestamp) {
|
||||
// sleep → awake 순서: sleep~awake 사이가 야간
|
||||
voidingRecords.filter { it.timestamp in lastSleep.timestamp..lastAwake.timestamp }
|
||||
} else {
|
||||
// awake → sleep 순서: sleep 이후가 현재 야간
|
||||
voidingRecords.filter { it.timestamp >= lastSleep.timestamp }
|
||||
}
|
||||
} else if (lastSleep != null) {
|
||||
voidingRecords.filter { it.timestamp >= lastSleep.timestamp }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val totalVolume = voidingRecords.sumOf { it.volume ?: 0 }
|
||||
val nocturnalVolume = nocturnalRecords.sumOf { it.volume ?: 0 }
|
||||
val npi = if (totalVolume > 0) nocturnalVolume.toDouble() / totalVolume else 0.0
|
||||
val maxCapacity = voidingRecords.maxOfOrNull { it.volume ?: 0 } ?: 0
|
||||
val nightCount = nocturnalRecords.size
|
||||
val dayCount = voidingRecords.size - nightCount
|
||||
|
||||
return DiaryMetrics(
|
||||
totalVolume24h = totalVolume,
|
||||
nocturnalVolume = nocturnalVolume,
|
||||
npi = npi,
|
||||
maxCapacity = maxCapacity,
|
||||
totalCount = voidingRecords.size,
|
||||
daytimeCount = dayCount,
|
||||
nighttimeCount = nightCount
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryBar(records: List<VoidingRecord>) {
|
||||
val volumes = records.mapNotNull { it.volume }
|
||||
val count = volumes.size
|
||||
val total = volumes.sum()
|
||||
val avg = if (count > 0) total / count else 0
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterHorizontally)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Icon(Icons.Default.WaterDrop, contentDescription = null, Modifier.size(14.dp), Color(0xFF2196F3))
|
||||
Text(stringResource(R.string.count_times, count), fontSize = 12.sp, color = Color(0xFF2196F3))
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Icon(Icons.Default.Science, contentDescription = null, Modifier.size(14.dp), Color(0xFF00BCD4))
|
||||
Text(stringResource(R.string.volume_ml_short, total), fontSize = 12.sp, color = Color(0xFF00BCD4))
|
||||
}
|
||||
if (count > 0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Icon(Icons.Default.BarChart, contentDescription = null, Modifier.size(14.dp), Color(0xFF4CAF50))
|
||||
Text(stringResource(R.string.average_ml, avg), fontSize = 12.sp, color = Color(0xFF4CAF50))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MedicalMetricsCard(metrics: DiaryMetrics) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.medical_metrics_24h), fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
||||
HorizontalDivider(color = MlSecondaryText.copy(alpha = 0.15f))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
MetricCell("24hUV", stringResource(R.string.volume_ml_short, metrics.totalVolume24h), Modifier.weight(1f))
|
||||
MetricCell("NUV", stringResource(R.string.volume_ml_short, metrics.nocturnalVolume), Modifier.weight(1f))
|
||||
MetricCell("NPI", String.format("%.0f%%", metrics.npi * 100), Modifier.weight(1f),
|
||||
highlight = metrics.npi > 0.33)
|
||||
MetricCell("Max", stringResource(R.string.volume_ml_short, metrics.maxCapacity), Modifier.weight(1f))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
MetricCell(stringResource(R.string.metric_total_count), stringResource(R.string.count_times, metrics.totalCount), Modifier.weight(1f))
|
||||
MetricCell(stringResource(R.string.metric_daytime), stringResource(R.string.count_times, metrics.daytimeCount), Modifier.weight(1f))
|
||||
MetricCell(stringResource(R.string.metric_nighttime), stringResource(R.string.count_times, metrics.nighttimeCount), Modifier.weight(1f),
|
||||
highlight = metrics.nighttimeCount >= 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetricCell(label: String, value: String, modifier: Modifier = Modifier, highlight: Boolean = false) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (highlight) Color.Red.copy(alpha = 0.08f) else Color.Gray.copy(alpha = 0.06f))
|
||||
.padding(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(value, fontSize = 16.sp, fontWeight = FontWeight.Bold,
|
||||
color = if (highlight) Color.Red else Color(0xFF1C1B1F))
|
||||
Text(label, fontSize = 10.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.medithings.vesiscan.ui.views.onboarding
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun OnboardingView(appState: AppState) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MlBackground),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
BladdyIdleRiveView(size = 180.dp)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_welcome),
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_subtitle),
|
||||
fontSize = 16.sp,
|
||||
color = MlSecondaryText,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(2f))
|
||||
|
||||
Button(
|
||||
onClick = { appState.completeOnboarding() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 48.dp)
|
||||
.height(56.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_get_started), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
package com.medithings.vesiscan.ui.views.personalization
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* 회의 #8 통합: MaxCapacity + Catheter Threshold + Auto Scan Interval 3카드 한 화면.
|
||||
* 캐릭터는 작게(상단), 카드 영역은 verticalScroll로 모두 노출.
|
||||
*/
|
||||
@Composable
|
||||
fun PiezoPersonalizationView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
val bleManager = remember { BleManager.getInstance(context) }
|
||||
val handler = remember { Handler(Looper.getMainLooper()) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { appState.backToSensorSelect() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.personalization_back_desc), tint = MlPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// Bladdy hero — 회의 #8: 작게 (이전 220 → 120dp)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
BladdyIdleRiveView(size = 120.dp)
|
||||
}
|
||||
|
||||
// 3카드 영역 — verticalScroll
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
MaxVolumeCard(appState)
|
||||
CatheterThresholdCard(appState)
|
||||
AutoScanIntervalCard(appState)
|
||||
WakeupAlarmCard()
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Continue button
|
||||
Button(
|
||||
onClick = { appState.personalizationComplete() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
.height(56.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||
RoundedCornerShape(16.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(R.string.calibration_continue), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MaxVolumeCard(appState: AppState) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlPrimary.copy(alpha = 0.06f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(stringResource(R.string.personalization_max_capacity), fontSize = 28.sp, fontWeight = FontWeight.ExtraBold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text("${appState.piezoSettings.maxVolume}", fontSize = 48.sp,
|
||||
fontWeight = FontWeight.ExtraBold, color = MlPrimary)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("mL", fontSize = 20.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText,
|
||||
modifier = Modifier.padding(bottom = 8.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
|
||||
StepperButton(icon = Icons.Default.Remove) {
|
||||
appState.piezoSettings = appState.piezoSettings.copy(
|
||||
maxVolume = (appState.piezoSettings.maxVolume - 50).coerceIn(200, 800)
|
||||
)
|
||||
}
|
||||
StepperButton(icon = Icons.Default.Add) {
|
||||
appState.piezoSettings = appState.piezoSettings.copy(
|
||||
maxVolume = (appState.piezoSettings.maxVolume + 50).coerceIn(200, 800)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Slider(
|
||||
value = appState.piezoSettings.maxVolume.toFloat(),
|
||||
onValueChange = {
|
||||
appState.piezoSettings = appState.piezoSettings.copy(maxVolume = it.roundToInt())
|
||||
},
|
||||
valueRange = 200f..800f,
|
||||
steps = 11,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatheterThresholdCard(appState: AppState) {
|
||||
val threshold = appState.bladderLevel.catheterizationThreshold
|
||||
val maxVol = appState.piezoSettings.maxVolume
|
||||
val pct = threshold * 10 // level 1~10 → 10~100%
|
||||
val ml = (maxVol * pct / 100) // 자동 환산
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlTeal.copy(alpha = 0.06f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(stringResource(R.string.personalization_voiding_reminder_title), fontSize = 28.sp, fontWeight = FontWeight.ExtraBold)
|
||||
Text(stringResource(R.string.voiding_reminder_threshold_desc),
|
||||
fontSize = 12.sp, color = MlSecondaryText,
|
||||
modifier = Modifier.padding(top = 2.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text("$pct", fontSize = 48.sp,
|
||||
fontWeight = FontWeight.ExtraBold, color = MlPrimary)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("%", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = MlPrimary,
|
||||
modifier = Modifier.padding(bottom = 6.dp))
|
||||
}
|
||||
Text("≈ $ml mL (of $maxVol mL)",
|
||||
fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
|
||||
StepperButton(icon = Icons.Default.Remove) {
|
||||
appState.updateCatheterizationThreshold((threshold - 1).coerceIn(1, 10))
|
||||
}
|
||||
StepperButton(icon = Icons.Default.Add) {
|
||||
appState.updateCatheterizationThreshold((threshold + 1).coerceIn(1, 10))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Slider(
|
||||
value = threshold.toFloat(),
|
||||
onValueChange = {
|
||||
appState.updateCatheterizationThreshold(it.roundToInt().coerceIn(1, 10))
|
||||
},
|
||||
valueRange = 1f..10f,
|
||||
steps = 8,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회의 #9: Auto Scan Interval 카드.
|
||||
* 범위 0.5 ~ 10, 0.5 단위. sec/min 토글이 숫자 우측에 위치.
|
||||
* - SEC 모드: ms = value * 1000 (예: 1.5sec = 1500ms)
|
||||
* - MIN 모드: ms = value * 60_000 (예: 1.5min = 90000ms)
|
||||
*/
|
||||
@Composable
|
||||
private fun AutoScanIntervalCard(appState: AppState) {
|
||||
// 저장된 ms 값에서 현재 단위와 표시값 추론
|
||||
var unitMode by remember {
|
||||
mutableStateOf(if (appState.piezoSettings.autoScanIntervalMs < 30_000) "sec" else "min")
|
||||
}
|
||||
val currentMs = appState.piezoSettings.autoScanIntervalMs
|
||||
val displayValue = if (unitMode == "sec") currentMs / 1000.0 else currentMs / 60_000.0
|
||||
val valueClamped = displayValue.coerceIn(0.5, 10.0)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlPrimary.copy(alpha = 0.04f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(stringResource(R.string.piezo_settings_auto_scan_interval), fontSize = 28.sp, fontWeight = FontWeight.ExtraBold)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 숫자 + sec/min 토글 (우측)
|
||||
Row(verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.Center) {
|
||||
Text("%.1f".format(valueClamped), fontSize = 48.sp,
|
||||
fontWeight = FontWeight.ExtraBold, color = MlPrimary)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
) {
|
||||
UnitToggle(label = "sec", selected = unitMode == "sec") {
|
||||
if (unitMode != "sec") {
|
||||
// 단위 전환 시 같은 숫자 값 유지 (0.5 → 0.5sec)
|
||||
val newMs = (valueClamped * 1000).toInt()
|
||||
unitMode = "sec"
|
||||
appState.piezoSettings = appState.piezoSettings.copy(autoScanIntervalMs = newMs)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
UnitToggle(label = "min", selected = unitMode == "min") {
|
||||
if (unitMode != "min") {
|
||||
val newMs = (valueClamped * 60_000).toInt()
|
||||
unitMode = "min"
|
||||
appState.piezoSettings = appState.piezoSettings.copy(autoScanIntervalMs = newMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 스테퍼 ±0.5
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
|
||||
StepperButton(icon = Icons.Default.Remove) {
|
||||
val next = (valueClamped - 0.5).coerceIn(0.5, 10.0)
|
||||
val newMs = if (unitMode == "sec") (next * 1000).toInt() else (next * 60_000).toInt()
|
||||
appState.piezoSettings = appState.piezoSettings.copy(autoScanIntervalMs = newMs)
|
||||
}
|
||||
StepperButton(icon = Icons.Default.Add) {
|
||||
val next = (valueClamped + 0.5).coerceIn(0.5, 10.0)
|
||||
val newMs = if (unitMode == "sec") (next * 1000).toInt() else (next * 60_000).toInt()
|
||||
appState.piezoSettings = appState.piezoSettings.copy(autoScanIntervalMs = newMs)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 슬라이더 0.5 ~ 10, 0.5 step (20 ticks → steps=19)
|
||||
Slider(
|
||||
value = valueClamped.toFloat(),
|
||||
onValueChange = {
|
||||
val snapped = ((it * 2).roundToInt() / 2.0).coerceIn(0.5, 10.0)
|
||||
val newMs = if (unitMode == "sec") (snapped * 1000).toInt() else (snapped * 60_000).toInt()
|
||||
appState.piezoSettings = appState.piezoSettings.copy(autoScanIntervalMs = newMs)
|
||||
},
|
||||
valueRange = 0.5f..10f,
|
||||
steps = 19,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnitToggle(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(if (selected) MlPrimary else Color.Gray.copy(alpha = 0.15f))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold,
|
||||
color = if (selected) Color.White else Color.Black)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepperButton(icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MlPrimary)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(icon, null, tint = Color.White, modifier = Modifier.size(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.compose.runtime.Composable
|
||||
private fun WakeupAlarmCard() {
|
||||
val context = LocalContext.current
|
||||
var enabled by androidx.compose.runtime.remember {
|
||||
androidx.compose.runtime.mutableStateOf(
|
||||
com.medithings.vesiscan.services.WakeupAlarmPrefs.isEnabled(context)
|
||||
)
|
||||
}
|
||||
Card(
|
||||
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFD32F2F).copy(alpha = 0.06f))
|
||||
) {
|
||||
androidx.compose.foundation.layout.Row(
|
||||
modifier = androidx.compose.ui.Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
) {
|
||||
androidx.compose.foundation.layout.Column(
|
||||
modifier = androidx.compose.ui.Modifier.weight(1f)
|
||||
) {
|
||||
androidx.compose.material3.Text(
|
||||
stringResource(R.string.wakeup_alarm_title), fontSize = 24.sp, fontWeight = FontWeight.ExtraBold
|
||||
)
|
||||
androidx.compose.foundation.layout.Spacer(
|
||||
modifier = androidx.compose.ui.Modifier.height(4.dp)
|
||||
)
|
||||
androidx.compose.material3.Text(
|
||||
stringResource(R.string.wakeup_alarm_desc),
|
||||
fontSize = 12.sp, color = MlSecondaryText
|
||||
)
|
||||
}
|
||||
androidx.compose.material3.Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = {
|
||||
enabled = it
|
||||
com.medithings.vesiscan.services.WakeupAlarmPrefs.setEnabled(context, it)
|
||||
},
|
||||
colors = androidx.compose.material3.SwitchDefaults.colors(
|
||||
checkedTrackColor = Color(0xFFD32F2F)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.medithings.vesiscan.ui.views.pin
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.services.UserStorage
|
||||
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
|
||||
enum class PinMode { SETUP, ENTER }
|
||||
|
||||
@Composable
|
||||
fun PinView(appState: AppState, mode: PinMode, context: android.content.Context) {
|
||||
// DEMO: 자동 통과 — PIN 저장 없이 즉시 verified 처리
|
||||
LaunchedEffect(Unit) { appState.pinVerified() }
|
||||
|
||||
// DEMO: UI 본체 비표시 — 빈 배경만 노출, 원본은 if(false) 안에 보존
|
||||
Box(modifier = Modifier.fillMaxSize().background(MlBackground))
|
||||
|
||||
if (false) {
|
||||
var enteredPin by remember { mutableStateOf("") }
|
||||
var confirmPin by remember { mutableStateOf("") }
|
||||
var isConfirming by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf("") }
|
||||
val pinLength = 4
|
||||
|
||||
val currentPin = if (isConfirming) confirmPin else enteredPin
|
||||
|
||||
val title = when (mode) {
|
||||
PinMode.SETUP -> if (isConfirming) "Re-enter PIN" else "Set PIN"
|
||||
PinMode.ENTER -> "Enter PIN"
|
||||
}
|
||||
|
||||
fun addDigit(digit: Int) {
|
||||
errorMessage = ""
|
||||
if (isConfirming) {
|
||||
if (confirmPin.length >= pinLength) return
|
||||
confirmPin += digit.toString()
|
||||
if (confirmPin.length == pinLength) {
|
||||
// Verify setup
|
||||
if (confirmPin == enteredPin) {
|
||||
appState.pinSet(enteredPin)
|
||||
} else {
|
||||
errorMessage = "PINs don't match. Try again."
|
||||
enteredPin = ""
|
||||
confirmPin = ""
|
||||
isConfirming = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (enteredPin.length >= pinLength) return
|
||||
enteredPin += digit.toString()
|
||||
if (enteredPin.length == pinLength) {
|
||||
when (mode) {
|
||||
PinMode.SETUP -> isConfirming = true
|
||||
PinMode.ENTER -> {
|
||||
val stored = UserStorage.getInstance(context).pin
|
||||
if (enteredPin == stored) {
|
||||
appState.pinVerified()
|
||||
} else {
|
||||
errorMessage = "Incorrect PIN"
|
||||
enteredPin = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteDigit() {
|
||||
if (isConfirming) {
|
||||
if (confirmPin.isNotEmpty()) confirmPin = confirmPin.dropLast(1)
|
||||
} else {
|
||||
if (enteredPin.isNotEmpty()) enteredPin = enteredPin.dropLast(1)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MlBackground),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
BladdyIdleRiveView(size = 100.dp)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(title, fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||
|
||||
if (errorMessage.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(errorMessage, fontSize = 14.sp, color = Color.Red)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Dot indicators
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
repeat(pinLength) { index ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (index < currentPin.length) MlPrimary
|
||||
else Color.Gray.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Shuffled keypad
|
||||
ShuffledKeypad(
|
||||
onDigitTap = { addDigit(it) },
|
||||
onDelete = { deleteDigit() }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
} // end if (false) — DEMO 본체 비표시
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.medithings.vesiscan.ui.views.pin
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Backspace
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.ui.theme.MlCardBackground
|
||||
|
||||
@Composable
|
||||
fun ShuffledKeypad(
|
||||
onDigitTap: (Int) -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val digits = (1..9).toList() + listOf(0)
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.padding(horizontal = 40.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
userScrollEnabled = false
|
||||
) {
|
||||
// Digits 1-9
|
||||
items(9) { index ->
|
||||
KeyButton(digit = digits[index], onClick = { onDigitTap(digits[index]) })
|
||||
}
|
||||
// Empty space
|
||||
item { Spacer(modifier = Modifier.height(60.dp)) }
|
||||
// Digit 0
|
||||
item { KeyButton(digit = 0, onClick = { onDigitTap(0) }) }
|
||||
// Delete
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(60.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.Gray.copy(alpha = 0.1f))
|
||||
.clickable(onClick = onDelete),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Backspace,
|
||||
contentDescription = stringResource(R.string.pin_delete),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyButton(digit: Int, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(60.dp)
|
||||
.shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.06f))
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MlCardBackground)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "$digit",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.medithings.vesiscan.ui.views.registration
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.models.Sex
|
||||
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun RegistrationView(appState: AppState) {
|
||||
var firstName by remember { mutableStateOf("") }
|
||||
var lastName by remember { mutableStateOf("") }
|
||||
var ageText by remember { mutableStateOf("") }
|
||||
var selectedSex by remember { mutableStateOf<Sex?>(null) }
|
||||
var heightText by remember { mutableStateOf("") }
|
||||
var weightText by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MlBackground)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
BladdyIdleRiveView(size = 100.dp)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text("User Registration", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text("Enter your information", fontSize = 14.sp, color = MlSecondaryText)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Name
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
RegistrationTextField("First Name", firstName, { firstName = it })
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
RegistrationTextField("Last Name", lastName, { lastName = it })
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Age + Sex
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
RegistrationTextField("Age", ageText, { ageText = it }, KeyboardType.Number)
|
||||
}
|
||||
listOf(Sex.MALE to stringResource(R.string.sex_male), Sex.FEMALE to stringResource(R.string.sex_female)).forEach { (sex, label) ->
|
||||
val selected = selectedSex == sex
|
||||
OutlinedButton(
|
||||
onClick = { selectedSex = sex },
|
||||
modifier = Modifier.height(52.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (selected) MlPrimary.copy(alpha = 0.1f) else Color.Transparent
|
||||
),
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp, if (selected) MlPrimary else MlInputBorder.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
Text(label, color = if (selected) MlPrimary else MlSecondaryText, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Height + Weight
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
RegistrationTextField("Height", heightText, { heightText = it }, KeyboardType.Decimal, "cm")
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
RegistrationTextField("Weight", weightText, { weightText = it }, KeyboardType.Decimal, "kg")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
// Complete button
|
||||
Button(
|
||||
onClick = {
|
||||
appState.user = appState.user.copy(
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
age = ageText.toIntOrNull() ?: 0,
|
||||
sex = selectedSex,
|
||||
heightCm = heightText.toDoubleOrNull() ?: 0.0,
|
||||
weightKg = weightText.toDoubleOrNull() ?: 0.0
|
||||
)
|
||||
appState.completeRegistration()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.height(52.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Complete", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip button
|
||||
TextButton(
|
||||
onClick = { appState.completeRegistration() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
Text("Skip", fontSize = 14.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RegistrationTextField(
|
||||
placeholder: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
suffix: String? = null
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = { Text(placeholder) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||
suffix = suffix?.let { { Text(it, color = MlSecondaryText) } },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MlInputBackground, RoundedCornerShape(12.dp))
|
||||
.border(1.dp, MlInputBorder.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = MlInputBorder
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package com.medithings.vesiscan.ui.views.reminder
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.services.ReminderService
|
||||
import com.medithings.vesiscan.ui.components.LockableSlider
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReminderSettingsView(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val reminderService = remember { ReminderService.getInstance(context) }
|
||||
|
||||
var isEnabled by remember { mutableStateOf(reminderService.isEnabled) }
|
||||
var intervalMinutes by remember { mutableIntStateOf(reminderService.intervalMinutes) }
|
||||
var startHour by remember { mutableIntStateOf(reminderService.startHour) }
|
||||
var endHour by remember { mutableIntStateOf(reminderService.endHour) }
|
||||
var useSound by remember { mutableStateOf(reminderService.useSound) }
|
||||
var useVibration by remember { mutableStateOf(reminderService.useVibration) }
|
||||
var usePopup by remember { mutableStateOf(reminderService.usePopup) }
|
||||
var isFlexible by remember { mutableStateOf(reminderService.isFlexible) }
|
||||
|
||||
val intervalOptions = listOf(30, 60, 90, 120, 150, 180, 240)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.reminder_title), fontWeight = FontWeight.SemiBold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// ON/OFF
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.reminder_enable), fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
if (isEnabled) stringResource(R.string.reminder_enabled_desc)
|
||||
else stringResource(R.string.reminder_disabled_desc),
|
||||
fontSize = 12.sp, color = MlSecondaryText
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isEnabled,
|
||||
onCheckedChange = {
|
||||
isEnabled = it
|
||||
reminderService.isEnabled = it
|
||||
},
|
||||
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnabled) {
|
||||
// Interval (list with checkmarks, matching iOS)
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.reminder_interval_label), fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
intervalOptions.forEach { minutes ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
intervalMinutes = minutes
|
||||
reminderService.intervalMinutes = minutes
|
||||
reminderService.reschedule()
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
formatInterval(minutes, context),
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (intervalMinutes == minutes) {
|
||||
Text("✓", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlPrimary)
|
||||
}
|
||||
}
|
||||
if (minutes != intervalOptions.last()) {
|
||||
HorizontalDivider(color = Color.LightGray.copy(alpha = 0.3f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Active hours
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.reminder_active_hours), fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.reminder_start), fontSize = 13.sp, color = MlSecondaryText, modifier = Modifier.width(40.dp))
|
||||
Text(stringResource(R.string.reminder_hour_format, startHour), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
||||
}
|
||||
LockableSlider(
|
||||
value = startHour.toFloat(),
|
||||
onValueChange = {
|
||||
startHour = it.toInt()
|
||||
reminderService.startHour = startHour
|
||||
if (endHour <= startHour) {
|
||||
endHour = startHour + 1
|
||||
reminderService.endHour = endHour
|
||||
}
|
||||
reminderService.reschedule()
|
||||
},
|
||||
valueRange = 5f..23f,
|
||||
steps = 17,
|
||||
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.reminder_end), fontSize = 13.sp, color = MlSecondaryText, modifier = Modifier.width(40.dp))
|
||||
Text(stringResource(R.string.reminder_hour_format, endHour), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
||||
}
|
||||
LockableSlider(
|
||||
value = endHour.toFloat(),
|
||||
onValueChange = {
|
||||
endHour = it.toInt()
|
||||
reminderService.endHour = endHour
|
||||
reminderService.reschedule()
|
||||
},
|
||||
valueRange = (startHour + 1).toFloat()..24f,
|
||||
steps = (24 - startHour - 2).coerceAtLeast(0),
|
||||
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Flexible (toggle, matching iOS)
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.reminder_flexible_title), fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
Text(stringResource(R.string.reminder_flexible_desc), fontSize = 12.sp, color = MlSecondaryText)
|
||||
}
|
||||
Switch(
|
||||
checked = isFlexible,
|
||||
onCheckedChange = {
|
||||
isFlexible = it
|
||||
reminderService.isFlexible = it
|
||||
reminderService.reschedule()
|
||||
},
|
||||
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Alert methods (sound, vibration, popup)
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.reminder_method_label), fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_sound), modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = useSound,
|
||||
onCheckedChange = {
|
||||
useSound = it
|
||||
reminderService.useSound = it
|
||||
reminderService.reschedule()
|
||||
},
|
||||
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_vibration), modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = useVibration,
|
||||
onCheckedChange = {
|
||||
useVibration = it
|
||||
reminderService.useVibration = it
|
||||
reminderService.reschedule()
|
||||
},
|
||||
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_popup), modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = usePopup,
|
||||
onCheckedChange = {
|
||||
usePopup = it
|
||||
reminderService.usePopup = it
|
||||
reminderService.reschedule()
|
||||
},
|
||||
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.reminder_summary), fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("🕐", fontSize = 14.sp)
|
||||
Text(stringResource(R.string.reminder_active_hours_summary, startHour, endHour), fontSize = 14.sp, color = MlPrimary)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("🔁", fontSize = 14.sp)
|
||||
Text(stringResource(R.string.reminder_interval_summary, formatInterval(intervalMinutes, context)), fontSize = 14.sp, color = Color(0xFFF5A623))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Permission banner
|
||||
if (!reminderService.permissionGranted) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF3E0))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("⚠️", fontSize = 20.sp)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.reminder_permission_needed), fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.reminder_permission_desc), fontSize = 12.sp, color = MlSecondaryText)
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}) {
|
||||
Text(stringResource(R.string.settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatInterval(minutes: Int, context: android.content.Context): String {
|
||||
val h = minutes / 60
|
||||
val m = minutes % 60
|
||||
return when {
|
||||
h > 0 && m > 0 -> context.getString(R.string.format_hours_minutes, h, m)
|
||||
h > 0 -> context.getString(R.string.format_hours, h)
|
||||
else -> context.getString(R.string.format_minutes, m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.medithings.vesiscan.ui.views.sensorselect
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.R
|
||||
import com.medithings.vesiscan.models.SensorMode
|
||||
import com.medithings.vesiscan.ui.components.BladdyIdleRiveView
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun SensorSelectView(appState: AppState) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MlBackground),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
BladdyIdleRiveView(size = 140.dp)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(stringResource(R.string.sensor_select_title), fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(stringResource(R.string.sensor_select_subtitle), fontSize = 16.sp, color = MlSecondaryText)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
SensorCard(
|
||||
mode = SensorMode.NIRS,
|
||||
title = "NIRS",
|
||||
description = stringResource(R.string.sensor_nirs_desc),
|
||||
gradientColors = listOf(Color(0xFF00DBA1), Color(0xFF008FFF)),
|
||||
iconText = "NIRS",
|
||||
onClick = { appState.selectSensor(SensorMode.NIRS) }
|
||||
)
|
||||
|
||||
SensorCard(
|
||||
mode = SensorMode.PIEZO,
|
||||
title = "Piezo",
|
||||
description = stringResource(R.string.sensor_piezo_desc),
|
||||
gradientColors = listOf(Color(0xFF8F44FC), Color(0xFFF04A8F)),
|
||||
iconText = "Piezo",
|
||||
onClick = { appState.selectSensor(SensorMode.PIEZO) }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SensorCard(
|
||||
mode: SensorMode,
|
||||
title: String,
|
||||
description: String,
|
||||
gradientColors: List<Color>,
|
||||
iconText: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.shadow(8.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.06f))
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Brush.linearGradient(gradientColors)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(iconText, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(20.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(description, fontSize = 14.sp, color = MlSecondaryText)
|
||||
}
|
||||
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MlSecondaryText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user