UI: PiezoPersonalizationView 전면 리디자인 — 평행 2-카드

빌드 107-119 적용:
- Bladdy 히어로 BoxWithConstraints 동적 사이징, 카드 영역 수직 중앙 정렬
- Card 1 "Maximum Bladder Capacity" 34sp ExtraBold + 500/mL (64+24sp) + 56dp 솔리드 StepperButton + 슬라이더
- Card 2 "Catheter Threshold" 34sp ExtraBold + Level/7/10 3분할(24+64+24sp) + StepperButton + Level 1~10 슬라이더
- 부제 모두 제거, 섹션 간격 64dp
- Slider 값 부동소수점 절삭 버그 fix: it.toInt() → it.roundToInt()

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 16:02:24 +09:00
parent 975be77126
commit 3e1a875b5b
@@ -23,29 +23,20 @@ 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.min
import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppState
import com.example.medilightv2android.ble.BleManager
import com.example.medilightv2android.models.*
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
import com.example.medilightv2android.ui.theme.*
import kotlin.math.roundToInt
@Composable
fun PiezoPersonalizationView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
val handler = remember { Handler(Looper.getMainLooper()) }
var currentStep by remember { mutableIntStateOf(1) }
var optimizationComplete by remember { mutableStateOf(false) }
val totalSteps = 1
fun startOptimization() {
bleManager.onPiezoDataReceived = {
optimizationComplete = true
}
bleManager.sendPiezoPowerOn(freqOption = 2, cycles = 5)
handler.postDelayed({ optimizationComplete = true }, 6000)
}
Column(
modifier = Modifier
@@ -64,197 +55,174 @@ fun PiezoPersonalizationView(appState: AppState) {
}
}
Spacer(modifier = Modifier.height(24.dp))
// Max Volume만 표시
when (currentStep) {
1 -> MaxVolumeStep(appState)
// Bladdy hero — 동적 사이징
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
val bladdySize = min(maxWidth, 220.dp)
BladdyIdleRiveView(size = bladdySize)
}
Spacer(modifier = Modifier.weight(1f))
// Navigation button
if (currentStep <= totalSteps) {
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("Continue", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
}
}
}
}
}
@Composable
private fun PostureStep(appState: AppState) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Select Posture", fontSize = 24.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text("Choose your measurement position", fontSize = 14.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(24.dp))
Column(modifier = Modifier.padding(horizontal = 32.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Posture.entries.forEach { posture ->
val isSelected = appState.piezoSettings.posture == posture
SelectionCard(
title = posture.displayName,
isSelected = isSelected,
onClick = { appState.piezoSettings = appState.piezoSettings.copy(posture = posture) }
)
}
}
}
}
@Composable
private fun SensorPositionStep(appState: AppState) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Sensor Position", fontSize = 24.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text("Place the sensor on your body", fontSize = 14.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(24.dp))
Column(modifier = Modifier.padding(horizontal = 32.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
SensorPosition.entries.forEach { position ->
val isSelected = appState.piezoSettings.sensorPosition == position
SelectionCard(
title = position.displayName,
subtitle = position.description,
isSelected = isSelected,
onClick = { appState.piezoSettings = appState.piezoSettings.copy(sensorPosition = position) }
)
}
}
}
}
@Composable
private fun MaxVolumeStep(appState: AppState) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Bladder Capacity", fontSize = 24.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text("Set your maximum bladder capacity (mL)", fontSize = 14.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(24.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text("${appState.piezoSettings.maxVolume}", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
Spacer(modifier = Modifier.width(8.dp))
Text("mL", fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText,
modifier = Modifier.padding(bottom = 10.dp))
// 카드 영역 — 수직 중앙 정렬
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(64.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally
) {
MaxVolumeCard(appState)
CatheterThresholdCard(appState)
}
Spacer(modifier = Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
IconButton(onClick = {
appState.piezoSettings = appState.piezoSettings.copy(
maxVolume = (appState.piezoSettings.maxVolume - 50).coerceIn(200, 800)
)
}) {
Icon(Icons.Default.Remove, null, tint = MlPrimary, modifier = Modifier.size(44.dp))
}
IconButton(onClick = {
appState.piezoSettings = appState.piezoSettings.copy(
maxVolume = (appState.piezoSettings.maxVolume + 50).coerceIn(200, 800)
)
}) {
Icon(Icons.Default.Add, null, tint = MlPrimary, modifier = Modifier.size(44.dp))
}
}
Slider(
value = appState.piezoSettings.maxVolume.toFloat(),
onValueChange = { appState.piezoSettings = appState.piezoSettings.copy(maxVolume = it.toInt()) },
valueRange = 200f..800f,
steps = 11,
modifier = Modifier.padding(horizontal = 48.dp),
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)) {
Text("200ml", fontSize = 12.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.weight(1f))
Text("800ml", fontSize = 12.sp, color = MlSecondaryText)
}
}
}
@Composable
private fun OptimizationStep(isComplete: Boolean) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Text(
if (isComplete) "Optimization Complete" else "Optimizing...",
fontSize = 24.sp, fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(24.dp))
BladdyIdleRiveView(size = 160.dp)
Spacer(modifier = Modifier.height(24.dp))
if (isComplete) {
Icon(Icons.Default.CheckCircle, null, tint = MlTeal, modifier = Modifier.size(48.dp))
} else {
CircularProgressIndicator(color = MlPrimary)
Spacer(modifier = Modifier.height(16.dp))
Text("Calibrating sensor parameters", fontSize = 14.sp, color = MlSecondaryText)
}
}
}
@Composable
private fun SelectionCard(title: String, subtitle: String? = null, isSelected: Boolean, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.then(if (isSelected) Modifier.border(2.dp, MlPrimary, RoundedCornerShape(16.dp)) else Modifier),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
// 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
.size(if (subtitle != null) 48.dp else 56.dp)
.clip(RoundedCornerShape(if (subtitle != null) 12.dp else 14.dp))
.background(if (isSelected) MlPrimary else MlPrimary.copy(alpha = 0.1f)),
modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
RoundedCornerShape(16.dp)
),
contentAlignment = Alignment.Center
) {
Text(title.take(3), color = if (isSelected) Color.White else MlPrimary, fontWeight = FontWeight.Bold, fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, fontSize = if (subtitle != null) 16.sp else 18.sp, fontWeight = FontWeight.SemiBold)
subtitle?.let {
Spacer(modifier = Modifier.height(2.dp))
Text(it, fontSize = 13.sp, color = MlSecondaryText)
}
}
if (isSelected) {
Icon(Icons.Default.CheckCircle, null, tint = MlPrimary, modifier = Modifier.size(24.dp))
Text("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 = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Maximum Bladder Capacity", fontSize = 34.sp, fontWeight = FontWeight.ExtraBold)
Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text("${appState.piezoSettings.maxVolume}", fontSize = 64.sp,
fontWeight = FontWeight.ExtraBold, color = MlPrimary)
Spacer(modifier = Modifier.width(8.dp))
Text("mL", fontSize = 24.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText,
modifier = Modifier.padding(bottom = 12.dp))
}
Spacer(modifier = Modifier.height(12.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(12.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
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 = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Catheter Threshold", fontSize = 34.sp, fontWeight = FontWeight.ExtraBold)
Spacer(modifier = Modifier.height(16.dp))
// "Level 7 / 10" — 3분할 픽셀 매칭 (Card 1 "500 mL"와 동일 톤)
Row(verticalAlignment = Alignment.Bottom) {
Text("Level", fontSize = 24.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText,
modifier = Modifier.padding(bottom = 12.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("$threshold", fontSize = 64.sp,
fontWeight = FontWeight.ExtraBold, color = MlPrimary)
Spacer(modifier = Modifier.width(8.dp))
Text("/10", fontSize = 24.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText,
modifier = Modifier.padding(bottom = 12.dp))
}
Spacer(modifier = Modifier.height(12.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(12.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)
)
}
}
}
@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))
}
}