From d8db7671ac983a4c4aa792c97c04be3ad3cc5fc2 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 22 Apr 2026 14:03:02 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20iOS=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=E2=80=94=20=EC=95=8C=EB=A6=BC=EC=84=A4=EC=A0=95,=20Quick=20Sav?= =?UTF-8?q?e,=20=EC=9D=BC=EC=A7=80=20=EC=9A=94=EC=95=BD=EB=B0=94,=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=ED=86=A0=ED=81=B0,=20BLE=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=A0=9C=EA=B1=B0,=20=EA=B7=B8=EB=9E=98=ED=94=84?= =?UTF-8?q?=20=EC=B6=95=C2=B7=ED=81=B4=EB=A6=BD=C2=B7Placement=20=ED=99=95?= =?UTF-8?q?=EB=8C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../services/ReminderService.kt | 35 ++- .../ui/theme/DesignTokens.kt | 32 +- .../ui/views/connection/DeviceScanView.kt | 6 +- .../views/monitoring/PiezoMonitoringView.kt | 289 ++++++++++++------ .../ui/views/monitoring/PlacementGuideView.kt | 208 ++++++++++--- .../ui/views/monitoring/VoidingDiaryView.kt | 40 ++- .../ui/views/reminder/ReminderSettingsView.kt | 175 ++++++++--- 7 files changed, 563 insertions(+), 222 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/services/ReminderService.kt b/app/src/main/java/com/example/medilightv2android/services/ReminderService.kt index 34c3908..92949d2 100644 --- a/app/src/main/java/com/example/medilightv2android/services/ReminderService.kt +++ b/app/src/main/java/com/example/medilightv2android/services/ReminderService.kt @@ -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 { diff --git a/app/src/main/java/com/example/medilightv2android/ui/theme/DesignTokens.kt b/app/src/main/java/com/example/medilightv2android/ui/theme/DesignTokens.kt index f93ca9a..fba1ea4 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/theme/DesignTokens.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/theme/DesignTokens.kt @@ -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 } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt index 7009d06..89c885a 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt @@ -44,10 +44,8 @@ fun DeviceScanView(appState: AppState) { var connectingDeviceAddress by remember { mutableStateOf(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)) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index dce03d6..c017178 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -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) + } + } } } } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 97998c1..56699ce 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -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>(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): Pair // Partial return "Adjust sensor position slowly" to "arrow.up.arrow.down" } + +@Composable +private fun PlacementWaveformChart( + buffer: List, + 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) + } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VoidingDiaryView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VoidingDiaryView.kt index f96c9b3..23e428f 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VoidingDiaryView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VoidingDiaryView.kt @@ -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): DiaryMetrics nocturnalVolume = nocturnalVolume, npi = npi, maxCapacity = maxCapacity, + totalCount = voidingRecords.size, daytimeCount = dayCount, nighttimeCount = nightCount ) } +@Composable +private fun SummaryBar(records: List) { + 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) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/reminder/ReminderSettingsView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/reminder/ReminderSettingsView.kt index f631f09..5f65a60 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/reminder/ReminderSettingsView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/reminder/ReminderSettingsView.kt @@ -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("설정") + } + } } } @@ -235,4 +314,4 @@ private fun formatInterval(minutes: Int): String { h > 0 -> "${h}시간" else -> "${m}분" } -} +} \ No newline at end of file