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:
+161
-193
@@ -23,29 +23,20 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.min
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.example.medilightv2android.AppState
|
import com.example.medilightv2android.AppState
|
||||||
import com.example.medilightv2android.ble.BleManager
|
import com.example.medilightv2android.ble.BleManager
|
||||||
import com.example.medilightv2android.models.*
|
import com.example.medilightv2android.models.*
|
||||||
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
|
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
|
||||||
import com.example.medilightv2android.ui.theme.*
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PiezoPersonalizationView(appState: AppState) {
|
fun PiezoPersonalizationView(appState: AppState) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val bleManager = remember { BleManager.getInstance(context) }
|
val bleManager = remember { BleManager.getInstance(context) }
|
||||||
val handler = remember { Handler(Looper.getMainLooper()) }
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -64,197 +55,174 @@ fun PiezoPersonalizationView(appState: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
// Bladdy hero — 동적 사이징
|
||||||
|
BoxWithConstraints(
|
||||||
// Max Volume만 표시
|
modifier = Modifier
|
||||||
when (currentStep) {
|
.fillMaxWidth()
|
||||||
1 -> MaxVolumeStep(appState)
|
.padding(vertical = 8.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val bladdySize = min(maxWidth, 220.dp)
|
||||||
|
BladdyIdleRiveView(size = bladdySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
// 카드 영역 — 수직 중앙 정렬
|
||||||
|
Column(
|
||||||
// Navigation button
|
modifier = Modifier
|
||||||
if (currentStep <= totalSteps) {
|
.weight(1f)
|
||||||
Button(
|
.fillMaxWidth()
|
||||||
onClick = { appState.personalizationComplete() },
|
.padding(horizontal = 24.dp),
|
||||||
modifier = Modifier
|
verticalArrangement = Arrangement.spacedBy(64.dp, Alignment.CenterVertically),
|
||||||
.fillMaxWidth()
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
.padding(horizontal = 32.dp)
|
) {
|
||||||
.padding(bottom = 32.dp)
|
MaxVolumeCard(appState)
|
||||||
.height(56.dp),
|
CatheterThresholdCard(appState)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
// Continue button
|
||||||
|
Button(
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
|
onClick = { appState.personalizationComplete() },
|
||||||
IconButton(onClick = {
|
modifier = Modifier
|
||||||
appState.piezoSettings = appState.piezoSettings.copy(
|
.fillMaxWidth()
|
||||||
maxVolume = (appState.piezoSettings.maxVolume - 50).coerceIn(200, 800)
|
.padding(horizontal = 32.dp)
|
||||||
)
|
.padding(bottom = 32.dp)
|
||||||
}) {
|
.height(56.dp),
|
||||||
Icon(Icons.Default.Remove, null, tint = MlPrimary, modifier = Modifier.size(44.dp))
|
shape = RoundedCornerShape(16.dp),
|
||||||
}
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
IconButton(onClick = {
|
contentPadding = PaddingValues()
|
||||||
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
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize().background(
|
||||||
.size(if (subtitle != null) 48.dp else 56.dp)
|
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||||
.clip(RoundedCornerShape(if (subtitle != null) 12.dp else 14.dp))
|
RoundedCornerShape(16.dp)
|
||||||
.background(if (isSelected) MlPrimary else MlPrimary.copy(alpha = 0.1f)),
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(title.take(3), color = if (isSelected) Color.White else MlPrimary, fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
Text("Continue", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user