feat: iOS 동기화 — 알림설정, Quick Save, 일지 요약바, 디자인토큰, BLE필터 제거, 그래프 축·클립·Placement 확대

- ReminderSettingsView: 간격 리스트+체크, 유동 토글, 팝업 알림, 요약, 권한 배너 (iOS ReminderView 동일)
- ReminderService: 기본값 iOS 동기화(120분, 8시), 5종 랜덤 메시지, usePopup/isFlexible/permissionGranted
- PiezoMonitoringView: 배뇨기록 다이얼로그 iOS DiaryRecordSheet 스타일 + Quick Save
- VoidingDiaryView: 요약 바(건수+총량+평균), 의료지표 4+3 배열, 총 횟수 추가
- DesignTokens: iOS DS enum과 동일 값 (44dp touch, 12dp medium radius, spacing md=16/lg=24/xl=32)
- DeviceScanView: BLE prefix 필터 제거 (VBT 등 신규 기기 지원)
- WaveformChart: Y축(amplitude)/X축(depth mm) 라벨, clip으로 셀 오버플로 방지
- PlacementGuideView: 센서 레이아웃 확대(300→400dp), 스캔 후 채널별 에코 그래프 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 14:03:02 +09:00
parent 5a084ac5a9
commit d8db7671ac
7 changed files with 563 additions and 222 deletions
@@ -51,11 +51,11 @@ class ReminderService private constructor(private val context: Context) {
}
var intervalMinutes: Int
get() = prefs.getInt("interval_minutes", 180) // default 3h
get() = prefs.getInt("interval_minutes", 120)
set(value) { prefs.edit().putInt("interval_minutes", value).apply() }
var startHour: Int
get() = prefs.getInt("start_hour", 7)
get() = prefs.getInt("start_hour", 8)
set(value) { prefs.edit().putInt("start_hour", value).apply() }
var endHour: Int
@@ -70,9 +70,16 @@ class ReminderService private constructor(private val context: Context) {
get() = prefs.getBoolean("use_vibration", true)
set(value) { prefs.edit().putBoolean("use_vibration", value).apply() }
var flexMinutes: Int
get() = prefs.getInt("flex_minutes", 0)
set(value) { prefs.edit().putInt("flex_minutes", value).apply() }
var usePopup: Boolean
get() = prefs.getBoolean("use_popup", true)
set(value) { prefs.edit().putBoolean("use_popup", value).apply() }
var isFlexible: Boolean
get() = prefs.getBoolean("is_flexible", true)
set(value) { prefs.edit().putBoolean("is_flexible", value).apply() }
val permissionGranted: Boolean
get() = NotificationManagerCompat.from(context).areNotificationsEnabled()
fun scheduleNext() {
if (!isEnabled) return
@@ -141,16 +148,30 @@ class ReminderService private constructor(private val context: Context) {
alarmManager.cancel(pendingIntent)
}
private val reminderMessages = listOf(
"배뇨 시간입니다! 도뇨 준비를 해주세요.",
"규칙적인 배뇨는 건강의 기본입니다!",
"화장실에 다녀오세요! 💧",
"배뇨 시간이에요. 건강한 습관을 유지하세요!",
"잊지 마세요! 화장실 갈 시간입니다."
)
fun showNotification() {
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("배뇨 시간")
.setContentText("도뇨 시간입니다. 배뇨를 진행해 주세요.")
.setContentTitle("VesiScan 알림")
.setContentText(reminderMessages.random())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
if (useSound) {
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
} else {
builder.setSilent(true)
}
if (!usePopup) {
builder.setPriority(NotificationCompat.PRIORITY_LOW)
}
try {
@@ -28,31 +28,27 @@ fun Modifier.pressScale(onClick: () -> Unit): Modifier = composed {
}
object DesignTokens {
// Button
// Button heights (iOS: buttonPrimary=56, buttonSecondary=48)
val ButtonHeight = 56.dp
val ButtonHeightSmall = 44.dp
val ButtonHeightSecondary = 48.dp
// Touch targets
val MinTouchTarget = 48.dp
// Touch targets (iOS: minTouchTarget=44)
val MinTouchTarget = 44.dp
// Corner radius
// Corner radius (iOS: small=8, medium=12, large=16)
val CornerRadiusSmall = 8.dp
val CornerRadiusMedium = 14.dp
val CornerRadiusMedium = 12.dp
val CornerRadiusLarge = 16.dp
val CornerRadiusPill = 20.dp
// Spacing
// Spacing (iOS: xs=4, sm=8, md=16, lg=24, xl=32)
val SpacingXs = 4.dp
val SpacingSm = 8.dp
val SpacingMd = 12.dp
val SpacingLg = 16.dp
val SpacingXl = 20.dp
val SpacingXxl = 24.dp
val SpacingPage = 32.dp
val SpacingMd = 16.dp
val SpacingLg = 24.dp
val SpacingXl = 32.dp
// Icon sizes
val IconSmall = 16.dp
val IconMedium = 20.dp
val IconDefault = 24.dp
val IconLarge = 48.dp
// Icon sizes (iOS: small=14, medium=18, large=24)
val IconSmall = 14.dp
val IconMedium = 18.dp
val IconLarge = 24.dp
}
@@ -44,10 +44,8 @@ fun DeviceScanView(appState: AppState) {
var connectingDeviceAddress by remember { mutableStateOf<String?>(null) }
var permissionsGranted by remember { mutableStateOf(false) }
val devicePrefix = if (appState.sensorMode == SensorMode.NIRS) "2025A04DEV" else "2025MEDIP"
val filteredDevices = bleManager.discoveredDevices
.filter { it.name.startsWith(devicePrefix) }
.filter { it.name.isNotBlank() }
.sortedByDescending { it.rssi }
val isConnected by bleManager.isConnected
@@ -130,7 +128,7 @@ fun DeviceScanView(appState: AppState) {
) {
Text("Connect Device", fontSize = 24.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text("Searching for $devicePrefix devices...", fontSize = 14.sp, color = MlSecondaryText)
Text("Searching for nearby devices...", fontSize = 14.sp, color = MlSecondaryText)
}
Spacer(modifier = Modifier.height(24.dp))
@@ -247,28 +247,104 @@ fun PiezoMonitoringView(appState: AppState) {
)
}
// 배뇨 기록 입력 방식 선택 다이얼로그
// ��뇨 기록 입력 방식 선택 (iOS DiaryRecordSheet 스타일)
if (showCatheterizeSheet) {
AlertDialog(
onDismissRequest = { showCatheterizeSheet = false },
title = { Text(stringResource(R.string.voiding_record), fontWeight = FontWeight.Bold) },
text = { Text(stringResource(R.string.select_input_method)) },
title = {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.voiding_record), fontWeight = FontWeight.Bold, fontSize = 18.sp)
Spacer(modifier = Modifier.height(4.dp))
Text(
"${appState.estimatedVolume} ml",
fontSize = 28.sp, fontWeight = FontWeight.Bold, color = MlPrimary
)
Text("추정 용량", fontSize = 12.sp, color = MlSecondaryText)
}
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// 수기 입력
Surface(
onClick = { showCatheterizeSheet = false; showManualInput = true },
shape = RoundedCornerShape(12.dp),
color = Color(0xFFFFF3E0)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Edit, contentDescription = null, tint = Color(0xFFFF9800))
Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.manual_input), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText)
}
}
// 음성 입력
Surface(
onClick = { showCatheterizeSheet = false; showVoiceInput = true },
shape = RoundedCornerShape(12.dp),
color = Color(0xFFE3F2FD)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Mic, contentDescription = null, tint = Color(0xFF2196F3))
Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.voice_input), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText)
}
}
// 카메라 입력
Surface(
onClick = { showCatheterizeSheet = false; showUrineCamera = true },
shape = RoundedCornerShape(12.dp),
color = Color(0xFFF3E5F5)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.CameraAlt, contentDescription = null, tint = Color(0xFF9C27B0))
Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.camera_input), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText)
}
}
// Quick Save (iOS bolt.fill 스타일)
Surface(
onClick = {
com.example.medilightv2android.measure.VoidingRecordStore.add(
context, com.example.medilightv2android.measure.VoidingRecord(
volume = appState.estimatedVolume, method = "catheterize"
)
)
appState.resetLevel()
resetMeasurement()
android.widget.Toast.makeText(context, context.getString(R.string.registered_diary), android.widget.Toast.LENGTH_SHORT).show()
showCatheterizeSheet = false
},
shape = RoundedCornerShape(12.dp),
color = Color(0xFFE8F5E9)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.FlashOn, contentDescription = null, tint = Color(0xFF4CAF50))
Spacer(modifier = Modifier.width(12.dp))
Text("빠른 저장", fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("${appState.estimatedVolume} ml", color = MlSecondaryText, fontSize = 14.sp)
Spacer(modifier = Modifier.width(4.dp))
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText)
}
}
}
},
confirmButton = {},
dismissButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = {
showCatheterizeSheet = false
showManualInput = true
}) { Text(stringResource(R.string.manual_input)) }
TextButton(onClick = {
showCatheterizeSheet = false
showVoiceInput = true
}) { Text(stringResource(R.string.voice_input)) }
TextButton(onClick = {
showCatheterizeSheet = false
showUrineCamera = true
}) { Text(stringResource(R.string.camera_input)) }
}
TextButton(onClick = { showCatheterizeSheet = false }) { Text(stringResource(R.string.cancel)) }
}
)
}
@@ -812,7 +888,7 @@ private fun ChannelPanel(
}
}
// ── Waveform Chart (Canvas-based) ──
// ── Waveform Chart (Canvas-based, with axis labels + clip) ──
@Composable
private fun WaveformChart(
@@ -832,90 +908,119 @@ private fun WaveformChart(
val denoised = analyzer.denoise(raw)
val maxVal = 2500f
val threshold = analyzer.lowEchoAmpDefault.toFloat()
val distPerSample = 1.771f // mm per sample
Canvas(modifier = modifier) {
val w = size.width
val h = size.height
val stepX = w / (count - 1).toFloat()
val yLabels = listOf(0, 500, 1000, 1500, 2000, 2500)
val xLabelCount = 5
// Background grid (4 horizontal lines)
for (i in 0..4) {
val y = h * i / 4f
drawLine(
color = Color.Gray.copy(alpha = 0.15f),
start = Offset(0f, y), end = Offset(w, y),
strokeWidth = 0.5f
)
Row(modifier = modifier.clip(RoundedCornerShape(4.dp))) {
// Y-axis labels
Column(
modifier = Modifier.fillMaxHeight().width(28.dp).padding(end = 2.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
yLabels.reversed().forEach { v ->
Text(
text = if (v >= 1000) "${v / 1000}k" else "$v",
fontSize = 7.sp, color = Color.Gray, lineHeight = 8.sp,
maxLines = 1
)
}
}
// Threshold line (orange dashed at 1600)
val threshY = h - (threshold / maxVal * h)
drawLine(
color = Color(0xFFFF9500),
start = Offset(0f, threshY), end = Offset(w, threshY),
strokeWidth = 1f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 6f))
)
// Chart area + X-axis
Column(modifier = Modifier.weight(1f)) {
Canvas(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(2.dp))
) {
val w = size.width
val h = size.height
val stepX = w / (count - 1).toFloat()
// Low-echo span highlight (yellow)
if (lowStart != null && lowEnd != null && lowEnd > lowStart) {
val lsX = lowStart * stepX
val leX = lowEnd * stepX
drawRect(
color = Color.Yellow.copy(alpha = 0.15f),
topLeft = Offset(lsX, 0f),
size = Size((leX - lsX).coerceAtLeast(1f), h)
)
}
// Background grid
for (i in 0..4) {
val y = h * i / 4f
drawLine(Color.Gray.copy(alpha = 0.15f), Offset(0f, y), Offset(w, y), strokeWidth = 0.5f)
}
// Urine region shading (cyan)
if (antIndex != null && postIndex != null && antIndex >= 0 && postIndex > antIndex && postIndex < count) {
val antX = antIndex * stepX
val postX = postIndex * stepX
drawRect(
color = Color.Cyan.copy(alpha = 0.15f),
topLeft = Offset(antX, 0f),
size = Size((postX - antX).coerceAtLeast(1f), h)
)
}
// Threshold line
val threshY = h - (threshold / maxVal * h)
drawLine(
Color(0xFFFF9500), Offset(0f, threshY), Offset(w, threshY),
strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 6f))
)
// Raw waveform (light blue)
val rawPath = Path()
for (i in raw.indices) {
val x = i * stepX
val y = h - (raw[i].toFloat() / maxVal * h)
if (i == 0) rawPath.moveTo(x, y) else rawPath.lineTo(x, y)
}
drawPath(rawPath, color = Color.Blue.copy(alpha = 0.3f), style = Stroke(width = 1.5f))
// Low-echo span highlight
if (lowStart != null && lowEnd != null && lowEnd > lowStart) {
drawRect(
Color.Yellow.copy(alpha = 0.15f),
Offset(lowStart * stepX, 0f),
Size(((lowEnd - lowStart) * stepX).coerceAtLeast(1f), h)
)
}
// Denoised waveform (solid blue)
val denoisedPath = Path()
for (i in denoised.indices) {
val x = i * stepX
val y = h - (denoised[i].toFloat() / maxVal * h)
if (i == 0) denoisedPath.moveTo(x, y) else denoisedPath.lineTo(x, y)
}
drawPath(denoisedPath, color = Color.Blue, style = Stroke(width = 2.5f))
// Urine region shading
if (antIndex != null && postIndex != null && antIndex >= 0 && postIndex > antIndex && postIndex < count) {
drawRect(
Color.Cyan.copy(alpha = 0.15f),
Offset(antIndex * stepX, 0f),
Size(((postIndex - antIndex) * stepX).coerceAtLeast(1f), h)
)
}
// Anterior wall marker (green line)
if (antIndex != null && antIndex in 0 until count) {
val antX = antIndex * stepX
drawLine(color = Color.Green, start = Offset(antX, 0f), end = Offset(antX, h), strokeWidth = 3f)
val antY = h - (denoised.getOrElse(antIndex) { 0.0 }.toFloat() / maxVal * h)
drawCircle(color = Color.Green, radius = 5f, center = Offset(antX, antY))
}
// Raw waveform
val rawPath = Path()
for (i in raw.indices) {
val x = i * stepX
val y = h - (raw[i].toFloat().coerceIn(0f, maxVal) / maxVal * h)
if (i == 0) rawPath.moveTo(x, y) else rawPath.lineTo(x, y)
}
drawPath(rawPath, Color.Blue.copy(alpha = 0.3f), style = Stroke(width = 1.5f))
// Posterior wall marker (red line)
if (postIndex != null && postIndex in 0 until count) {
val postX = postIndex * stepX
drawLine(color = Color.Red, start = Offset(postX, 0f), end = Offset(postX, h), strokeWidth = 3f)
val postY = h - (denoised.getOrElse(postIndex) { 0.0 }.toFloat() / maxVal * h)
drawCircle(color = Color.Red, radius = 5f, center = Offset(postX, postY))
} else if (peakIndex in 0 until count) {
// Fallback: show peak dot if no walls
val px = peakIndex * stepX
val py = h - (raw[peakIndex].toFloat() / maxVal * h)
drawCircle(color = Color.Red, radius = 4f, center = Offset(px, py))
// Denoised waveform
val denoisedPath = Path()
for (i in denoised.indices) {
val x = i * stepX
val y = h - (denoised[i].toFloat().coerceIn(0f, maxVal) / maxVal * h)
if (i == 0) denoisedPath.moveTo(x, y) else denoisedPath.lineTo(x, y)
}
drawPath(denoisedPath, Color.Blue, style = Stroke(width = 2.5f))
// Anterior wall marker
if (antIndex != null && antIndex in 0 until count) {
val antX = antIndex * stepX
drawLine(Color.Green, Offset(antX, 0f), Offset(antX, h), strokeWidth = 3f)
val antY = h - (denoised.getOrElse(antIndex) { 0.0 }.toFloat().coerceIn(0f, maxVal) / maxVal * h)
drawCircle(Color.Green, 5f, Offset(antX, antY))
}
// Posterior wall marker
if (postIndex != null && postIndex in 0 until count) {
val postX = postIndex * stepX
drawLine(Color.Red, Offset(postX, 0f), Offset(postX, h), strokeWidth = 3f)
val postY = h - (denoised.getOrElse(postIndex) { 0.0 }.toFloat().coerceIn(0f, maxVal) / maxVal * h)
drawCircle(Color.Red, 5f, Offset(postX, postY))
} else if (peakIndex in 0 until count) {
val px = peakIndex * stepX
val py = h - (raw[peakIndex].toFloat().coerceIn(0f, maxVal) / maxVal * h)
drawCircle(Color.Red, 4f, Offset(px, py))
}
}
// X-axis labels (depth in mm)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 1.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
for (i in 0 until xLabelCount) {
val sampleIdx = (count - 1) * i / (xLabelCount - 1)
val depthMm = sampleIdx * distPerSample
Text("${"%.0f".format(depthMm)}mm", fontSize = 7.sp, color = Color.Gray)
}
}
}
}
}
@@ -24,6 +24,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
@@ -61,6 +62,7 @@ fun PlacementGuideView(appState: AppState) {
var directionIcon by remember { mutableStateOf("") }
var scanCount by remember { mutableIntStateOf(0) }
var isLocked by remember { mutableStateOf(false) }
var lastChannelData by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
val detectedCount = channelDetected.count { it }
@@ -212,6 +214,56 @@ fun PlacementGuideView(appState: AppState) {
)
}
// Per-channel echo graphs (after scan)
if (lastChannelData.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Card(
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text("Echo Waveforms", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
for (chData in lastChannelData.filter { it.buffer.isNotEmpty() }) {
val chAnalysis = analyzer.analyzeChannel(chData.buffer, chData.channel)
val detected = if (chData.channel < channelDetected.size) channelDetected[chData.channel] else false
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.size(8.dp).clip(CircleShape)
.background(if (detected) Color(0xFF4CAF50) else Color.Red.copy(alpha = 0.4f))
)
Spacer(modifier = Modifier.width(4.dp))
Text("CH${chData.channel}", fontSize = 11.sp, fontWeight = FontWeight.Bold)
if (chAnalysis.result != null) {
val r = chAnalysis.result
Spacer(modifier = Modifier.width(6.dp))
Text(
"${"%.0f".format(r.antDepthMm)}–${"%.0f".format(r.postDepthMm)}mm",
fontSize = 9.sp, color = Color.Cyan
)
}
}
PlacementWaveformChart(
buffer = chData.buffer,
peakIndex = chData.peakIndex.toInt(),
analyzer = analyzer,
antIndex = chAnalysis.result?.ant,
postIndex = chAnalysis.result?.post,
modifier = Modifier.fillMaxWidth().height(80.dp)
)
}
if (chData.channel < lastChannelData.last().channel) {
HorizontalDivider(color = Color.Gray.copy(alpha = 0.2f))
}
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Check Position button
@@ -246,6 +298,7 @@ fun PlacementGuideView(appState: AppState) {
for (i in 0 until 6) {
channelDetected[i] = detected[i]
}
lastChannelData = channels
// Direction guidance
val hints = updateDirectionGuide(detected)
@@ -359,46 +412,46 @@ private fun SensorLayoutCanvas(
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
.height(400.dp),
contentAlignment = Alignment.Center
) {
Canvas(
modifier = Modifier
.width(280.dp)
.height(300.dp)
.width(340.dp)
.height(400.dp)
) {
val centerX = size.width / 2
val centerY = size.height / 2
// Body outline (rounded rectangle)
// Body outline (rounded rectangle) — enlarged
drawRoundRect(
color = Color.Gray.copy(alpha = 0.2f),
topLeft = Offset(centerX - 100f, centerY - 140f),
size = Size(200f, 280f),
cornerRadius = CornerRadius(30f),
topLeft = Offset(centerX - 140f, centerY - 185f),
size = Size(280f, 370f),
cornerRadius = CornerRadius(40f),
style = Stroke(width = 2f)
)
// Bladder outline (dashed ellipse)
// Bladder outline (dashed ellipse) — enlarged
drawOval(
color = Color.Cyan.copy(alpha = 0.3f),
topLeft = Offset(centerX - 60f, centerY - 60f),
size = Size(120f, 100f),
topLeft = Offset(centerX - 85f, centerY - 80f),
size = Size(170f, 140f),
style = Stroke(
width = 1.5f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 6f))
)
)
// Sensor positions: channel -> (offsetX, offsetY)
data class SensorPos(val ch: Int, val x: Float, val y: Float, val angle: Float, val label: String)
// Sensor positions — spread out more
data class SensorPos(val ch: Int, val x: Float, val y: Float, val angle: Float)
val sensors = listOf(
SensorPos(3, 0f, -80f, -10f, "CH3\n-10\u00B0"),
SensorPos(2, 0f, -25f, 0f, "CH2\n0\u00B0"),
SensorPos(1, 0f, 30f, 10f, "CH1\n+10\u00B0"),
SensorPos(0, 0f, 85f, 20f, "CH0\n+20\u00B0"),
SensorPos(4, -80f, -25f, 10f, "CH4\nL 5\u00B0"),
SensorPos(5, 80f, -25f, 10f, "CH5\nR 5\u00B0")
SensorPos(3, 0f, -120f, -10f),
SensorPos(2, 0f, -40f, 0f),
SensorPos(1, 0f, 40f, 10f),
SensorPos(0, 0f, 120f, 20f),
SensorPos(4, -110f, -40f, 10f),
SensorPos(5, 110f, -40f, 10f)
)
for (s in sensors) {
@@ -407,74 +460,68 @@ private fun SensorLayoutCanvas(
val sx = centerX + s.x
val sy = centerY + s.y
// Sensor body (rotated rounded rect)
rotate(degrees = s.angle, pivot = Offset(sx, sy)) {
drawRoundRect(
color = color,
topLeft = Offset(sx - 14f, sy - 7f),
size = Size(28f, 14f),
cornerRadius = CornerRadius(3f)
topLeft = Offset(sx - 20f, sy - 10f),
size = Size(40f, 20f),
cornerRadius = CornerRadius(4f)
)
}
}
// Direction arrow overlay
if (directionIcon.isNotEmpty() && !isLocked) {
val arrowLength = 30f
val arrowStroke = Stroke(width = 4f)
val arrowLength = 40f
when {
directionIcon.contains("up") -> {
val ax = centerX
val ay = centerY - 130f
val ax = centerX; val ay = centerY - 175f
drawLine(orangeColor, Offset(ax, ay + arrowLength), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 10f, ay + 10f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 10f, ay + 10f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 12f, ay + 12f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 12f, ay + 12f), Offset(ax, ay), strokeWidth = 4f)
}
directionIcon.contains("down") -> {
val ax = centerX
val ay = centerY + 130f
val ax = centerX; val ay = centerY + 175f
drawLine(orangeColor, Offset(ax, ay - arrowLength), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 10f, ay - 10f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 10f, ay - 10f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 12f, ay - 12f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 12f, ay - 12f), Offset(ax, ay), strokeWidth = 4f)
}
directionIcon.contains("left") -> {
val ax = centerX - 130f
val ay = centerY
val ax = centerX - 170f; val ay = centerY
drawLine(orangeColor, Offset(ax + arrowLength, ay), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 10f, ay - 10f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 10f, ay + 10f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 12f, ay - 12f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax + 12f, ay + 12f), Offset(ax, ay), strokeWidth = 4f)
}
directionIcon.contains("right") -> {
val ax = centerX + 130f
val ay = centerY
val ax = centerX + 170f; val ay = centerY
drawLine(orangeColor, Offset(ax - arrowLength, ay), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 10f, ay - 10f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 10f, ay + 10f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 12f, ay - 12f), Offset(ax, ay), strokeWidth = 4f)
drawLine(orangeColor, Offset(ax - 12f, ay + 12f), Offset(ax, ay), strokeWidth = 4f)
}
}
}
}
// Overlay text labels (outside Canvas for proper text rendering)
// Overlay text labels — repositioned for larger layout
data class LabelPos(val ch: Int, val x: Int, val y: Int, val label: String)
val labels = listOf(
LabelPos(3, 0, -80, "CH3\n-10\u00B0"),
LabelPos(2, 0, -25, "CH2\n0\u00B0"),
LabelPos(1, 0, 30, "CH1\n+10\u00B0"),
LabelPos(0, 0, 85, "CH0\n+20\u00B0"),
LabelPos(4, -80, -25, "CH4\nL 5\u00B0"),
LabelPos(5, 80, -25, "CH5\nR 5\u00B0")
LabelPos(3, 0, -120, "CH3\n-10\u00B0"),
LabelPos(2, 0, -40, "CH2\n0\u00B0"),
LabelPos(1, 0, 40, "CH1\n+10\u00B0"),
LabelPos(0, 0, 120, "CH0\n+20\u00B0"),
LabelPos(4, -110, -40, "CH4\nL 5\u00B0"),
LabelPos(5, 110, -40, "CH5\nR 5\u00B0")
)
for (lbl in labels) {
val detected = if (lbl.ch < channelDetected.size) channelDetected[lbl.ch] else false
Text(
text = lbl.label,
fontSize = 8.sp,
fontSize = 9.sp,
fontWeight = FontWeight.SemiBold,
color = if (detected) Color(0xFF4CAF50) else Color.Gray,
textAlign = TextAlign.Center,
lineHeight = 10.sp,
modifier = Modifier.offset(x = lbl.x.dp, y = (lbl.y + 14).dp)
lineHeight = 11.sp,
modifier = Modifier.offset(x = lbl.x.dp, y = (lbl.y + 18).dp)
)
}
}
@@ -520,3 +567,64 @@ private fun updateDirectionGuide(detected: List<Boolean>): Pair<String, String>
// Partial
return "Adjust sensor position slowly" to "arrow.up.arrow.down"
}
@Composable
private fun PlacementWaveformChart(
buffer: List<UShort>,
peakIndex: Int,
analyzer: PiezoEchoAnalyzer,
antIndex: Int?,
postIndex: Int?,
modifier: Modifier = Modifier
) {
val count = buffer.size
if (count < 2) return
val raw = buffer.map { it.toDouble() }.toDoubleArray()
val denoised = analyzer.denoise(raw)
val maxVal = 2500f
Canvas(modifier = modifier.clip(RoundedCornerShape(4.dp))) {
val w = size.width
val h = size.height
val stepX = w / (count - 1).toFloat()
for (i in 0..3) {
val y = h * i / 3f
drawLine(Color.Gray.copy(alpha = 0.1f), Offset(0f, y), Offset(w, y), strokeWidth = 0.5f)
}
if (antIndex != null && postIndex != null && antIndex >= 0 && postIndex > antIndex && postIndex < count) {
drawRect(
Color.Cyan.copy(alpha = 0.15f),
Offset(antIndex * stepX, 0f),
Size(((postIndex - antIndex) * stepX).coerceAtLeast(1f), h)
)
}
val rawPath = Path()
for (i in raw.indices) {
val x = i * stepX
val y = h - (raw[i].toFloat().coerceIn(0f, maxVal) / maxVal * h)
if (i == 0) rawPath.moveTo(x, y) else rawPath.lineTo(x, y)
}
drawPath(rawPath, Color.Blue.copy(alpha = 0.25f), style = Stroke(width = 1f))
val denoisedPath = Path()
for (i in denoised.indices) {
val x = i * stepX
val y = h - (denoised[i].toFloat().coerceIn(0f, maxVal) / maxVal * h)
if (i == 0) denoisedPath.moveTo(x, y) else denoisedPath.lineTo(x, y)
}
drawPath(denoisedPath, Color.Blue, style = Stroke(width = 2f))
if (antIndex != null && antIndex in 0 until count) {
val antX = antIndex * stepX
drawLine(Color.Green, Offset(antX, 0f), Offset(antX, h), strokeWidth = 2f)
}
if (postIndex != null && postIndex in 0 until count) {
val postX = postIndex * stepX
drawLine(Color.Red, Offset(postX, 0f), Offset(postX, h), strokeWidth = 2f)
}
}
}
@@ -72,6 +72,9 @@ fun VoidingDiaryView(onBack: () -> Unit) {
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// 요약 바 (iOS summaryBar: 건수 + 총량 + 평균)
item { SummaryBar(records) }
// 의료 지표 카드
item { MedicalMetricsCard(metrics) }
@@ -170,6 +173,7 @@ data class DiaryMetrics(
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 // 야간 횟수
)
@@ -212,11 +216,40 @@ private fun calculateMedicalMetrics(records: List<VoidingRecord>): DiaryMetrics
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("${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("${total} ml", 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("평균 ${avg} ml", fontSize = 12.sp, color = Color(0xFF4CAF50))
}
}
}
}
@Composable
private fun MedicalMetricsCard(metrics: DiaryMetrics) {
Card(
@@ -228,14 +261,15 @@ private fun MedicalMetricsCard(metrics: DiaryMetrics) {
Text("24시간 의료 지표", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
HorizontalDivider(color = MlSecondaryText.copy(alpha = 0.15f))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
MetricCell("24hUV", "${metrics.totalVolume24h} ml", Modifier.weight(1f))
MetricCell("NUV", "${metrics.nocturnalVolume} ml", Modifier.weight(1f))
MetricCell("NPI", String.format("%.0f%%", metrics.npi * 100), Modifier.weight(1f),
highlight = metrics.npi > 0.33)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetricCell("Max", "${metrics.maxCapacity} ml", Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
MetricCell("총 횟수", "${metrics.totalCount}회", Modifier.weight(1f))
MetricCell("주간", "${metrics.daytimeCount}회", Modifier.weight(1f))
MetricCell("야간", "${metrics.nighttimeCount}회", Modifier.weight(1f),
highlight = metrics.nighttimeCount >= 2)
@@ -1,6 +1,9 @@
package com.example.medilightv2android.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
@@ -11,6 +14,7 @@ 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.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -30,9 +34,10 @@ fun ReminderSettingsView(onBack: () -> Unit) {
var endHour by remember { mutableIntStateOf(reminderService.endHour) }
var useSound by remember { mutableStateOf(reminderService.useSound) }
var useVibration by remember { mutableStateOf(reminderService.useVibration) }
var flexMinutes by remember { mutableIntStateOf(reminderService.flexMinutes) }
var usePopup by remember { mutableStateOf(reminderService.usePopup) }
var isFlexible by remember { mutableStateOf(reminderService.isFlexible) }
val intervalOptions = listOf(60, 90, 120, 150, 180, 210, 240, 300, 360)
val intervalOptions = listOf(30, 60, 90, 120, 150, 180, 240)
Scaffold(
topBar = {
@@ -54,7 +59,7 @@ fun ReminderSettingsView(onBack: () -> Unit) {
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 알림 ON/OFF
// ON/OFF
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
@@ -65,7 +70,11 @@ fun ReminderSettingsView(onBack: () -> Unit) {
) {
Column(modifier = Modifier.weight(1f)) {
Text("알림 활성화", fontSize = 16.sp, fontWeight = FontWeight.Bold)
Text("정해진 간격으로 배뇨 알림을 받습니다", fontSize = 12.sp, color = MlSecondaryText)
Text(
if (isEnabled) "정해진 간격으로 배뇨 알림을 받습니다"
else "알림이 꺼져 있습니다",
fontSize = 12.sp, color = MlSecondaryText
)
}
Switch(
checked = isEnabled,
@@ -79,7 +88,7 @@ fun ReminderSettingsView(onBack: () -> Unit) {
}
if (isEnabled) {
// 간격 설정
// Interval (list with checkmarks, matching iOS)
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
@@ -87,23 +96,35 @@ fun ReminderSettingsView(onBack: () -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
Text("알림 간격", fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text(formatInterval(intervalMinutes), fontSize = 24.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
Spacer(modifier = Modifier.height(8.dp))
Slider(
value = intervalOptions.indexOf(intervalMinutes).coerceAtLeast(0).toFloat(),
onValueChange = {
val idx = it.toInt().coerceIn(0, intervalOptions.size - 1)
intervalMinutes = intervalOptions[idx]
reminderService.intervalMinutes = intervalMinutes
},
valueRange = 0f..(intervalOptions.size - 1).toFloat(),
steps = intervalOptions.size - 2,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
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),
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)
@@ -121,9 +142,14 @@ fun ReminderSettingsView(onBack: () -> Unit) {
onValueChange = {
startHour = it.toInt()
reminderService.startHour = startHour
if (endHour <= startHour) {
endHour = startHour + 1
reminderService.endHour = endHour
}
reminderService.reschedule()
},
valueRange = 0f..23f,
steps = 22,
valueRange = 5f..23f,
steps = 17,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
@@ -138,41 +164,41 @@ fun ReminderSettingsView(onBack: () -> Unit) {
onValueChange = {
endHour = it.toInt()
reminderService.endHour = endHour
reminderService.reschedule()
},
valueRange = 0f..23f,
steps = 22,
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)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("유동 스케줄링", fontSize = 14.sp, fontWeight = FontWeight.Bold)
Text("배뇨 기록 시 다음 알림 시간을 자동으로 재설정합니다", fontSize = 12.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("유동 범위: ", fontSize = 13.sp, color = MlSecondaryText)
Text("±${flexMinutes}분", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("유동 스케줄링", fontSize = 14.sp, fontWeight = FontWeight.Bold)
Text("마지막 기록 기준으로 다음 알림을 조정합니다", fontSize = 12.sp, color = MlSecondaryText)
}
Slider(
value = flexMinutes.toFloat(),
onValueChange = {
flexMinutes = it.toInt()
reminderService.flexMinutes = flexMinutes
Switch(
checked = isFlexible,
onCheckedChange = {
isFlexible = it
reminderService.isFlexible = it
reminderService.reschedule()
},
valueRange = 0f..30f,
steps = 5,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
}
}
// 알림 방식
// Alert methods (sound, vibration, popup)
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
@@ -190,6 +216,7 @@ fun ReminderSettingsView(onBack: () -> Unit) {
onCheckedChange = {
useSound = it
reminderService.useSound = it
reminderService.reschedule()
},
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
@@ -204,6 +231,22 @@ fun ReminderSettingsView(onBack: () -> Unit) {
onCheckedChange = {
useVibration = it
reminderService.useVibration = it
reminderService.reschedule()
},
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text("팝업", modifier = Modifier.weight(1f))
Switch(
checked = usePopup,
onCheckedChange = {
usePopup = it
reminderService.usePopup = it
reminderService.reschedule()
},
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
@@ -211,14 +254,50 @@ fun ReminderSettingsView(onBack: () -> Unit) {
}
}
// 저장 & 적용
Button(
onClick = { reminderService.reschedule() },
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
// Summary
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Text("알림 설정 적용", fontWeight = FontWeight.SemiBold, color = androidx.compose.ui.graphics.Color.White)
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("요약", fontSize = 14.sp, fontWeight = FontWeight.Bold)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("🕐", fontSize = 14.sp)
Text("활성 시간: ${startHour}시 ~ ${endHour}시", fontSize = 14.sp, color = MlPrimary)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("🔁", fontSize = 14.sp)
Text("매 ${formatInterval(intervalMinutes)} 간격", 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("알림 권한 필요", fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text("설정에서 알림 권한을 허용해주세요", 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("설정")
}
}
}
}