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:
2026-07-02 14:30:43 +09:00
parent 55d55225ab
commit bcbc7b440c
159 changed files with 2865 additions and 717 deletions
@@ -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)
}
}
}
}
}
}
}
@@ -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)) }
}
}
}
}
@@ -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)
}
}
}
}
@@ -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
)
}
}
}