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:
@@ -51,11 +51,11 @@ class ReminderService private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var intervalMinutes: Int
|
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() }
|
set(value) { prefs.edit().putInt("interval_minutes", value).apply() }
|
||||||
|
|
||||||
var startHour: Int
|
var startHour: Int
|
||||||
get() = prefs.getInt("start_hour", 7)
|
get() = prefs.getInt("start_hour", 8)
|
||||||
set(value) { prefs.edit().putInt("start_hour", value).apply() }
|
set(value) { prefs.edit().putInt("start_hour", value).apply() }
|
||||||
|
|
||||||
var endHour: Int
|
var endHour: Int
|
||||||
@@ -70,9 +70,16 @@ class ReminderService private constructor(private val context: Context) {
|
|||||||
get() = prefs.getBoolean("use_vibration", true)
|
get() = prefs.getBoolean("use_vibration", true)
|
||||||
set(value) { prefs.edit().putBoolean("use_vibration", value).apply() }
|
set(value) { prefs.edit().putBoolean("use_vibration", value).apply() }
|
||||||
|
|
||||||
var flexMinutes: Int
|
var usePopup: Boolean
|
||||||
get() = prefs.getInt("flex_minutes", 0)
|
get() = prefs.getBoolean("use_popup", true)
|
||||||
set(value) { prefs.edit().putInt("flex_minutes", value).apply() }
|
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() {
|
fun scheduleNext() {
|
||||||
if (!isEnabled) return
|
if (!isEnabled) return
|
||||||
@@ -141,16 +148,30 @@ class ReminderService private constructor(private val context: Context) {
|
|||||||
alarmManager.cancel(pendingIntent)
|
alarmManager.cancel(pendingIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val reminderMessages = listOf(
|
||||||
|
"배뇨 시간입니다! 도뇨 준비를 해주세요.",
|
||||||
|
"규칙적인 배뇨는 건강의 기본입니다!",
|
||||||
|
"화장실에 다녀오세요! 💧",
|
||||||
|
"배뇨 시간이에요. 건강한 습관을 유지하세요!",
|
||||||
|
"잊지 마세요! 화장실 갈 시간입니다."
|
||||||
|
)
|
||||||
|
|
||||||
fun showNotification() {
|
fun showNotification() {
|
||||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
.setContentTitle("배뇨 시간")
|
.setContentTitle("VesiScan 알림")
|
||||||
.setContentText("도뇨 시간입니다. 배뇨를 진행해 주세요.")
|
.setContentText(reminderMessages.random())
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
|
||||||
if (useSound) {
|
if (useSound) {
|
||||||
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
|
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
|
||||||
|
} else {
|
||||||
|
builder.setSilent(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usePopup) {
|
||||||
|
builder.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -28,31 +28,27 @@ fun Modifier.pressScale(onClick: () -> Unit): Modifier = composed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
object DesignTokens {
|
object DesignTokens {
|
||||||
// Button
|
// Button heights (iOS: buttonPrimary=56, buttonSecondary=48)
|
||||||
val ButtonHeight = 56.dp
|
val ButtonHeight = 56.dp
|
||||||
val ButtonHeightSmall = 44.dp
|
val ButtonHeightSecondary = 48.dp
|
||||||
|
|
||||||
// Touch targets
|
// Touch targets (iOS: minTouchTarget=44)
|
||||||
val MinTouchTarget = 48.dp
|
val MinTouchTarget = 44.dp
|
||||||
|
|
||||||
// Corner radius
|
// Corner radius (iOS: small=8, medium=12, large=16)
|
||||||
val CornerRadiusSmall = 8.dp
|
val CornerRadiusSmall = 8.dp
|
||||||
val CornerRadiusMedium = 14.dp
|
val CornerRadiusMedium = 12.dp
|
||||||
val CornerRadiusLarge = 16.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 SpacingXs = 4.dp
|
||||||
val SpacingSm = 8.dp
|
val SpacingSm = 8.dp
|
||||||
val SpacingMd = 12.dp
|
val SpacingMd = 16.dp
|
||||||
val SpacingLg = 16.dp
|
val SpacingLg = 24.dp
|
||||||
val SpacingXl = 20.dp
|
val SpacingXl = 32.dp
|
||||||
val SpacingXxl = 24.dp
|
|
||||||
val SpacingPage = 32.dp
|
|
||||||
|
|
||||||
// Icon sizes
|
// Icon sizes (iOS: small=14, medium=18, large=24)
|
||||||
val IconSmall = 16.dp
|
val IconSmall = 14.dp
|
||||||
val IconMedium = 20.dp
|
val IconMedium = 18.dp
|
||||||
val IconDefault = 24.dp
|
val IconLarge = 24.dp
|
||||||
val IconLarge = 48.dp
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -44,10 +44,8 @@ fun DeviceScanView(appState: AppState) {
|
|||||||
var connectingDeviceAddress by remember { mutableStateOf<String?>(null) }
|
var connectingDeviceAddress by remember { mutableStateOf<String?>(null) }
|
||||||
var permissionsGranted by remember { mutableStateOf(false) }
|
var permissionsGranted by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val devicePrefix = if (appState.sensorMode == SensorMode.NIRS) "2025A04DEV" else "2025MEDIP"
|
|
||||||
|
|
||||||
val filteredDevices = bleManager.discoveredDevices
|
val filteredDevices = bleManager.discoveredDevices
|
||||||
.filter { it.name.startsWith(devicePrefix) }
|
.filter { it.name.isNotBlank() }
|
||||||
.sortedByDescending { it.rssi }
|
.sortedByDescending { it.rssi }
|
||||||
|
|
||||||
val isConnected by bleManager.isConnected
|
val isConnected by bleManager.isConnected
|
||||||
@@ -130,7 +128,7 @@ fun DeviceScanView(appState: AppState) {
|
|||||||
) {
|
) {
|
||||||
Text("Connect Device", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
Text("Connect Device", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|||||||
+197
-92
@@ -247,28 +247,104 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배뇨 기록 입력 방식 선택 다이얼로그
|
// ��뇨 기록 입력 방식 선택 (iOS DiaryRecordSheet 스타일)
|
||||||
if (showCatheterizeSheet) {
|
if (showCatheterizeSheet) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showCatheterizeSheet = false },
|
onDismissRequest = { showCatheterizeSheet = false },
|
||||||
title = { Text(stringResource(R.string.voiding_record), fontWeight = FontWeight.Bold) },
|
title = {
|
||||||
text = { Text(stringResource(R.string.select_input_method)) },
|
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 = {},
|
confirmButton = {},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
TextButton(onClick = { showCatheterizeSheet = false }) { Text(stringResource(R.string.cancel)) }
|
||||||
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)) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -812,7 +888,7 @@ private fun ChannelPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Waveform Chart (Canvas-based) ──
|
// ── Waveform Chart (Canvas-based, with axis labels + clip) ──
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WaveformChart(
|
private fun WaveformChart(
|
||||||
@@ -832,90 +908,119 @@ private fun WaveformChart(
|
|||||||
val denoised = analyzer.denoise(raw)
|
val denoised = analyzer.denoise(raw)
|
||||||
val maxVal = 2500f
|
val maxVal = 2500f
|
||||||
val threshold = analyzer.lowEchoAmpDefault.toFloat()
|
val threshold = analyzer.lowEchoAmpDefault.toFloat()
|
||||||
|
val distPerSample = 1.771f // mm per sample
|
||||||
|
|
||||||
Canvas(modifier = modifier) {
|
val yLabels = listOf(0, 500, 1000, 1500, 2000, 2500)
|
||||||
val w = size.width
|
val xLabelCount = 5
|
||||||
val h = size.height
|
|
||||||
val stepX = w / (count - 1).toFloat()
|
|
||||||
|
|
||||||
// Background grid (4 horizontal lines)
|
Row(modifier = modifier.clip(RoundedCornerShape(4.dp))) {
|
||||||
for (i in 0..4) {
|
// Y-axis labels
|
||||||
val y = h * i / 4f
|
Column(
|
||||||
drawLine(
|
modifier = Modifier.fillMaxHeight().width(28.dp).padding(end = 2.dp),
|
||||||
color = Color.Gray.copy(alpha = 0.15f),
|
verticalArrangement = Arrangement.SpaceBetween
|
||||||
start = Offset(0f, y), end = Offset(w, y),
|
) {
|
||||||
strokeWidth = 0.5f
|
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)
|
// Chart area + X-axis
|
||||||
val threshY = h - (threshold / maxVal * h)
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
drawLine(
|
Canvas(
|
||||||
color = Color(0xFFFF9500),
|
modifier = Modifier
|
||||||
start = Offset(0f, threshY), end = Offset(w, threshY),
|
.fillMaxWidth()
|
||||||
strokeWidth = 1f,
|
.weight(1f)
|
||||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 6f))
|
.clip(RoundedCornerShape(2.dp))
|
||||||
)
|
) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val stepX = w / (count - 1).toFloat()
|
||||||
|
|
||||||
// Low-echo span highlight (yellow)
|
// Background grid
|
||||||
if (lowStart != null && lowEnd != null && lowEnd > lowStart) {
|
for (i in 0..4) {
|
||||||
val lsX = lowStart * stepX
|
val y = h * i / 4f
|
||||||
val leX = lowEnd * stepX
|
drawLine(Color.Gray.copy(alpha = 0.15f), Offset(0f, y), Offset(w, y), strokeWidth = 0.5f)
|
||||||
drawRect(
|
}
|
||||||
color = Color.Yellow.copy(alpha = 0.15f),
|
|
||||||
topLeft = Offset(lsX, 0f),
|
|
||||||
size = Size((leX - lsX).coerceAtLeast(1f), h)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Urine region shading (cyan)
|
// Threshold line
|
||||||
if (antIndex != null && postIndex != null && antIndex >= 0 && postIndex > antIndex && postIndex < count) {
|
val threshY = h - (threshold / maxVal * h)
|
||||||
val antX = antIndex * stepX
|
drawLine(
|
||||||
val postX = postIndex * stepX
|
Color(0xFFFF9500), Offset(0f, threshY), Offset(w, threshY),
|
||||||
drawRect(
|
strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 6f))
|
||||||
color = Color.Cyan.copy(alpha = 0.15f),
|
)
|
||||||
topLeft = Offset(antX, 0f),
|
|
||||||
size = Size((postX - antX).coerceAtLeast(1f), h)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Raw waveform (light blue)
|
// Low-echo span highlight
|
||||||
val rawPath = Path()
|
if (lowStart != null && lowEnd != null && lowEnd > lowStart) {
|
||||||
for (i in raw.indices) {
|
drawRect(
|
||||||
val x = i * stepX
|
Color.Yellow.copy(alpha = 0.15f),
|
||||||
val y = h - (raw[i].toFloat() / maxVal * h)
|
Offset(lowStart * stepX, 0f),
|
||||||
if (i == 0) rawPath.moveTo(x, y) else rawPath.lineTo(x, y)
|
Size(((lowEnd - lowStart) * stepX).coerceAtLeast(1f), h)
|
||||||
}
|
)
|
||||||
drawPath(rawPath, color = Color.Blue.copy(alpha = 0.3f), style = Stroke(width = 1.5f))
|
}
|
||||||
|
|
||||||
// Denoised waveform (solid blue)
|
// Urine region shading
|
||||||
val denoisedPath = Path()
|
if (antIndex != null && postIndex != null && antIndex >= 0 && postIndex > antIndex && postIndex < count) {
|
||||||
for (i in denoised.indices) {
|
drawRect(
|
||||||
val x = i * stepX
|
Color.Cyan.copy(alpha = 0.15f),
|
||||||
val y = h - (denoised[i].toFloat() / maxVal * h)
|
Offset(antIndex * stepX, 0f),
|
||||||
if (i == 0) denoisedPath.moveTo(x, y) else denoisedPath.lineTo(x, y)
|
Size(((postIndex - antIndex) * stepX).coerceAtLeast(1f), h)
|
||||||
}
|
)
|
||||||
drawPath(denoisedPath, color = Color.Blue, style = Stroke(width = 2.5f))
|
}
|
||||||
|
|
||||||
// Anterior wall marker (green line)
|
// Raw waveform
|
||||||
if (antIndex != null && antIndex in 0 until count) {
|
val rawPath = Path()
|
||||||
val antX = antIndex * stepX
|
for (i in raw.indices) {
|
||||||
drawLine(color = Color.Green, start = Offset(antX, 0f), end = Offset(antX, h), strokeWidth = 3f)
|
val x = i * stepX
|
||||||
val antY = h - (denoised.getOrElse(antIndex) { 0.0 }.toFloat() / maxVal * h)
|
val y = h - (raw[i].toFloat().coerceIn(0f, maxVal) / maxVal * h)
|
||||||
drawCircle(color = Color.Green, radius = 5f, center = Offset(antX, antY))
|
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)
|
// Denoised waveform
|
||||||
if (postIndex != null && postIndex in 0 until count) {
|
val denoisedPath = Path()
|
||||||
val postX = postIndex * stepX
|
for (i in denoised.indices) {
|
||||||
drawLine(color = Color.Red, start = Offset(postX, 0f), end = Offset(postX, h), strokeWidth = 3f)
|
val x = i * stepX
|
||||||
val postY = h - (denoised.getOrElse(postIndex) { 0.0 }.toFloat() / maxVal * h)
|
val y = h - (denoised[i].toFloat().coerceIn(0f, maxVal) / maxVal * h)
|
||||||
drawCircle(color = Color.Red, radius = 5f, center = Offset(postX, postY))
|
if (i == 0) denoisedPath.moveTo(x, y) else denoisedPath.lineTo(x, y)
|
||||||
} else if (peakIndex in 0 until count) {
|
}
|
||||||
// Fallback: show peak dot if no walls
|
drawPath(denoisedPath, Color.Blue, style = Stroke(width = 2.5f))
|
||||||
val px = peakIndex * stepX
|
|
||||||
val py = h - (raw[peakIndex].toFloat() / maxVal * h)
|
// Anterior wall marker
|
||||||
drawCircle(color = Color.Red, radius = 4f, center = Offset(px, py))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+158
-50
@@ -24,6 +24,7 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
import androidx.compose.ui.graphics.PathEffect
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.drawscope.rotate
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
@@ -61,6 +62,7 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
var directionIcon by remember { mutableStateOf("") }
|
var directionIcon by remember { mutableStateOf("") }
|
||||||
var scanCount by remember { mutableIntStateOf(0) }
|
var scanCount by remember { mutableIntStateOf(0) }
|
||||||
var isLocked by remember { mutableStateOf(false) }
|
var isLocked by remember { mutableStateOf(false) }
|
||||||
|
var lastChannelData by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
|
||||||
|
|
||||||
val detectedCount = channelDetected.count { it }
|
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))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Check Position button
|
// Check Position button
|
||||||
@@ -246,6 +298,7 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
for (i in 0 until 6) {
|
for (i in 0 until 6) {
|
||||||
channelDetected[i] = detected[i]
|
channelDetected[i] = detected[i]
|
||||||
}
|
}
|
||||||
|
lastChannelData = channels
|
||||||
|
|
||||||
// Direction guidance
|
// Direction guidance
|
||||||
val hints = updateDirectionGuide(detected)
|
val hints = updateDirectionGuide(detected)
|
||||||
@@ -359,46 +412,46 @@ private fun SensorLayoutCanvas(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(300.dp),
|
.height(400.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(280.dp)
|
.width(340.dp)
|
||||||
.height(300.dp)
|
.height(400.dp)
|
||||||
) {
|
) {
|
||||||
val centerX = size.width / 2
|
val centerX = size.width / 2
|
||||||
val centerY = size.height / 2
|
val centerY = size.height / 2
|
||||||
|
|
||||||
// Body outline (rounded rectangle)
|
// Body outline (rounded rectangle) — enlarged
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = Color.Gray.copy(alpha = 0.2f),
|
color = Color.Gray.copy(alpha = 0.2f),
|
||||||
topLeft = Offset(centerX - 100f, centerY - 140f),
|
topLeft = Offset(centerX - 140f, centerY - 185f),
|
||||||
size = Size(200f, 280f),
|
size = Size(280f, 370f),
|
||||||
cornerRadius = CornerRadius(30f),
|
cornerRadius = CornerRadius(40f),
|
||||||
style = Stroke(width = 2f)
|
style = Stroke(width = 2f)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bladder outline (dashed ellipse)
|
// Bladder outline (dashed ellipse) — enlarged
|
||||||
drawOval(
|
drawOval(
|
||||||
color = Color.Cyan.copy(alpha = 0.3f),
|
color = Color.Cyan.copy(alpha = 0.3f),
|
||||||
topLeft = Offset(centerX - 60f, centerY - 60f),
|
topLeft = Offset(centerX - 85f, centerY - 80f),
|
||||||
size = Size(120f, 100f),
|
size = Size(170f, 140f),
|
||||||
style = Stroke(
|
style = Stroke(
|
||||||
width = 1.5f,
|
width = 1.5f,
|
||||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 6f))
|
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 6f))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sensor positions: channel -> (offsetX, offsetY)
|
// Sensor positions — spread out more
|
||||||
data class SensorPos(val ch: Int, val x: Float, val y: Float, val angle: Float, val label: String)
|
data class SensorPos(val ch: Int, val x: Float, val y: Float, val angle: Float)
|
||||||
val sensors = listOf(
|
val sensors = listOf(
|
||||||
SensorPos(3, 0f, -80f, -10f, "CH3\n-10\u00B0"),
|
SensorPos(3, 0f, -120f, -10f),
|
||||||
SensorPos(2, 0f, -25f, 0f, "CH2\n0\u00B0"),
|
SensorPos(2, 0f, -40f, 0f),
|
||||||
SensorPos(1, 0f, 30f, 10f, "CH1\n+10\u00B0"),
|
SensorPos(1, 0f, 40f, 10f),
|
||||||
SensorPos(0, 0f, 85f, 20f, "CH0\n+20\u00B0"),
|
SensorPos(0, 0f, 120f, 20f),
|
||||||
SensorPos(4, -80f, -25f, 10f, "CH4\nL 5\u00B0"),
|
SensorPos(4, -110f, -40f, 10f),
|
||||||
SensorPos(5, 80f, -25f, 10f, "CH5\nR 5\u00B0")
|
SensorPos(5, 110f, -40f, 10f)
|
||||||
)
|
)
|
||||||
|
|
||||||
for (s in sensors) {
|
for (s in sensors) {
|
||||||
@@ -407,74 +460,68 @@ private fun SensorLayoutCanvas(
|
|||||||
val sx = centerX + s.x
|
val sx = centerX + s.x
|
||||||
val sy = centerY + s.y
|
val sy = centerY + s.y
|
||||||
|
|
||||||
// Sensor body (rotated rounded rect)
|
|
||||||
rotate(degrees = s.angle, pivot = Offset(sx, sy)) {
|
rotate(degrees = s.angle, pivot = Offset(sx, sy)) {
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = color,
|
color = color,
|
||||||
topLeft = Offset(sx - 14f, sy - 7f),
|
topLeft = Offset(sx - 20f, sy - 10f),
|
||||||
size = Size(28f, 14f),
|
size = Size(40f, 20f),
|
||||||
cornerRadius = CornerRadius(3f)
|
cornerRadius = CornerRadius(4f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direction arrow overlay
|
// Direction arrow overlay
|
||||||
if (directionIcon.isNotEmpty() && !isLocked) {
|
if (directionIcon.isNotEmpty() && !isLocked) {
|
||||||
val arrowLength = 30f
|
val arrowLength = 40f
|
||||||
val arrowStroke = Stroke(width = 4f)
|
|
||||||
when {
|
when {
|
||||||
directionIcon.contains("up") -> {
|
directionIcon.contains("up") -> {
|
||||||
val ax = centerX
|
val ax = centerX; val ay = centerY - 175f
|
||||||
val ay = centerY - 130f
|
|
||||||
drawLine(orangeColor, Offset(ax, ay + arrowLength), Offset(ax, ay), strokeWidth = 4f)
|
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 - 12f, ay + 12f), 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)
|
||||||
}
|
}
|
||||||
directionIcon.contains("down") -> {
|
directionIcon.contains("down") -> {
|
||||||
val ax = centerX
|
val ax = centerX; val ay = centerY + 175f
|
||||||
val ay = centerY + 130f
|
|
||||||
drawLine(orangeColor, Offset(ax, ay - arrowLength), Offset(ax, ay), strokeWidth = 4f)
|
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 - 12f, ay - 12f), 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)
|
||||||
}
|
}
|
||||||
directionIcon.contains("left") -> {
|
directionIcon.contains("left") -> {
|
||||||
val ax = centerX - 130f
|
val ax = centerX - 170f; val ay = centerY
|
||||||
val ay = centerY
|
|
||||||
drawLine(orangeColor, Offset(ax + arrowLength, ay), Offset(ax, ay), strokeWidth = 4f)
|
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 + 12f, ay - 12f), 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)
|
||||||
}
|
}
|
||||||
directionIcon.contains("right") -> {
|
directionIcon.contains("right") -> {
|
||||||
val ax = centerX + 130f
|
val ax = centerX + 170f; val ay = centerY
|
||||||
val ay = centerY
|
|
||||||
drawLine(orangeColor, Offset(ax - arrowLength, ay), Offset(ax, ay), strokeWidth = 4f)
|
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 - 12f, ay - 12f), 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
data class LabelPos(val ch: Int, val x: Int, val y: Int, val label: String)
|
||||||
val labels = listOf(
|
val labels = listOf(
|
||||||
LabelPos(3, 0, -80, "CH3\n-10\u00B0"),
|
LabelPos(3, 0, -120, "CH3\n-10\u00B0"),
|
||||||
LabelPos(2, 0, -25, "CH2\n0\u00B0"),
|
LabelPos(2, 0, -40, "CH2\n0\u00B0"),
|
||||||
LabelPos(1, 0, 30, "CH1\n+10\u00B0"),
|
LabelPos(1, 0, 40, "CH1\n+10\u00B0"),
|
||||||
LabelPos(0, 0, 85, "CH0\n+20\u00B0"),
|
LabelPos(0, 0, 120, "CH0\n+20\u00B0"),
|
||||||
LabelPos(4, -80, -25, "CH4\nL 5\u00B0"),
|
LabelPos(4, -110, -40, "CH4\nL 5\u00B0"),
|
||||||
LabelPos(5, 80, -25, "CH5\nR 5\u00B0")
|
LabelPos(5, 110, -40, "CH5\nR 5\u00B0")
|
||||||
)
|
)
|
||||||
for (lbl in labels) {
|
for (lbl in labels) {
|
||||||
val detected = if (lbl.ch < channelDetected.size) channelDetected[lbl.ch] else false
|
val detected = if (lbl.ch < channelDetected.size) channelDetected[lbl.ch] else false
|
||||||
Text(
|
Text(
|
||||||
text = lbl.label,
|
text = lbl.label,
|
||||||
fontSize = 8.sp,
|
fontSize = 9.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = if (detected) Color(0xFF4CAF50) else Color.Gray,
|
color = if (detected) Color(0xFF4CAF50) else Color.Gray,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
lineHeight = 10.sp,
|
lineHeight = 11.sp,
|
||||||
modifier = Modifier.offset(x = lbl.x.dp, y = (lbl.y + 14).dp)
|
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
|
// Partial
|
||||||
return "Adjust sensor position slowly" to "arrow.up.arrow.down"
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+37
-3
@@ -72,6 +72,9 @@ fun VoidingDiaryView(onBack: () -> Unit) {
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
|
// 요약 바 (iOS summaryBar: 건수 + 총량 + 평균)
|
||||||
|
item { SummaryBar(records) }
|
||||||
|
|
||||||
// 의료 지표 카드
|
// 의료 지표 카드
|
||||||
item { MedicalMetricsCard(metrics) }
|
item { MedicalMetricsCard(metrics) }
|
||||||
|
|
||||||
@@ -170,6 +173,7 @@ data class DiaryMetrics(
|
|||||||
val nocturnalVolume: Int, // NUV (nocturnal urine volume)
|
val nocturnalVolume: Int, // NUV (nocturnal urine volume)
|
||||||
val npi: Double, // Nocturnal Polyuria Index (NUV / 24hUV)
|
val npi: Double, // Nocturnal Polyuria Index (NUV / 24hUV)
|
||||||
val maxCapacity: Int, // Max single voiding volume
|
val maxCapacity: Int, // Max single voiding volume
|
||||||
|
val totalCount: Int, // 총 횟수
|
||||||
val daytimeCount: Int, // 주간 횟수
|
val daytimeCount: Int, // 주간 횟수
|
||||||
val nighttimeCount: Int // 야간 횟수
|
val nighttimeCount: Int // 야간 횟수
|
||||||
)
|
)
|
||||||
@@ -212,11 +216,40 @@ private fun calculateMedicalMetrics(records: List<VoidingRecord>): DiaryMetrics
|
|||||||
nocturnalVolume = nocturnalVolume,
|
nocturnalVolume = nocturnalVolume,
|
||||||
npi = npi,
|
npi = npi,
|
||||||
maxCapacity = maxCapacity,
|
maxCapacity = maxCapacity,
|
||||||
|
totalCount = voidingRecords.size,
|
||||||
daytimeCount = dayCount,
|
daytimeCount = dayCount,
|
||||||
nighttimeCount = nightCount
|
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
|
@Composable
|
||||||
private fun MedicalMetricsCard(metrics: DiaryMetrics) {
|
private fun MedicalMetricsCard(metrics: DiaryMetrics) {
|
||||||
Card(
|
Card(
|
||||||
@@ -228,14 +261,15 @@ private fun MedicalMetricsCard(metrics: DiaryMetrics) {
|
|||||||
Text("24시간 의료 지표", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
Text("24시간 의료 지표", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
||||||
HorizontalDivider(color = MlSecondaryText.copy(alpha = 0.15f))
|
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("24hUV", "${metrics.totalVolume24h} ml", Modifier.weight(1f))
|
||||||
MetricCell("NUV", "${metrics.nocturnalVolume} ml", Modifier.weight(1f))
|
MetricCell("NUV", "${metrics.nocturnalVolume} ml", Modifier.weight(1f))
|
||||||
MetricCell("NPI", String.format("%.0f%%", metrics.npi * 100), Modifier.weight(1f),
|
MetricCell("NPI", String.format("%.0f%%", metrics.npi * 100), Modifier.weight(1f),
|
||||||
highlight = metrics.npi > 0.33)
|
highlight = metrics.npi > 0.33)
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
MetricCell("Max", "${metrics.maxCapacity} ml", Modifier.weight(1f))
|
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.daytimeCount}회", Modifier.weight(1f))
|
||||||
MetricCell("야간", "${metrics.nighttimeCount}회", Modifier.weight(1f),
|
MetricCell("야간", "${metrics.nighttimeCount}회", Modifier.weight(1f),
|
||||||
highlight = metrics.nighttimeCount >= 2)
|
highlight = metrics.nighttimeCount >= 2)
|
||||||
|
|||||||
+127
-48
@@ -1,6 +1,9 @@
|
|||||||
package com.example.medilightv2android.ui.views.reminder
|
package com.example.medilightv2android.ui.views.reminder
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -11,6 +14,7 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
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
|
||||||
@@ -30,9 +34,10 @@ fun ReminderSettingsView(onBack: () -> Unit) {
|
|||||||
var endHour by remember { mutableIntStateOf(reminderService.endHour) }
|
var endHour by remember { mutableIntStateOf(reminderService.endHour) }
|
||||||
var useSound by remember { mutableStateOf(reminderService.useSound) }
|
var useSound by remember { mutableStateOf(reminderService.useSound) }
|
||||||
var useVibration by remember { mutableStateOf(reminderService.useVibration) }
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -54,7 +59,7 @@ fun ReminderSettingsView(onBack: () -> Unit) {
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// 알림 ON/OFF
|
// ON/OFF
|
||||||
Card(
|
Card(
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||||
@@ -65,7 +70,11 @@ fun ReminderSettingsView(onBack: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text("알림 활성화", fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
Text("알림 활성화", fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||||
Text("정해진 간격으로 배뇨 알림을 받습니다", fontSize = 12.sp, color = MlSecondaryText)
|
Text(
|
||||||
|
if (isEnabled) "정해진 간격으로 배뇨 알림을 받습니다"
|
||||||
|
else "알림이 꺼져 있습니다",
|
||||||
|
fontSize = 12.sp, color = MlSecondaryText
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Switch(
|
Switch(
|
||||||
checked = isEnabled,
|
checked = isEnabled,
|
||||||
@@ -79,7 +88,7 @@ fun ReminderSettingsView(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
// 간격 설정
|
// Interval (list with checkmarks, matching iOS)
|
||||||
Card(
|
Card(
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||||
@@ -87,23 +96,35 @@ fun ReminderSettingsView(onBack: () -> Unit) {
|
|||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text("알림 간격", fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
Text("알림 간격", fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(formatInterval(intervalMinutes), fontSize = 24.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
intervalOptions.forEach { minutes ->
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Row(
|
||||||
Slider(
|
modifier = Modifier
|
||||||
value = intervalOptions.indexOf(intervalMinutes).coerceAtLeast(0).toFloat(),
|
.fillMaxWidth()
|
||||||
onValueChange = {
|
.clickable {
|
||||||
val idx = it.toInt().coerceIn(0, intervalOptions.size - 1)
|
intervalMinutes = minutes
|
||||||
intervalMinutes = intervalOptions[idx]
|
reminderService.intervalMinutes = minutes
|
||||||
reminderService.intervalMinutes = intervalMinutes
|
reminderService.reschedule()
|
||||||
},
|
}
|
||||||
valueRange = 0f..(intervalOptions.size - 1).toFloat(),
|
.padding(vertical = 12.dp),
|
||||||
steps = intervalOptions.size - 2,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
) {
|
||||||
)
|
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(
|
Card(
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||||
@@ -121,9 +142,14 @@ fun ReminderSettingsView(onBack: () -> Unit) {
|
|||||||
onValueChange = {
|
onValueChange = {
|
||||||
startHour = it.toInt()
|
startHour = it.toInt()
|
||||||
reminderService.startHour = startHour
|
reminderService.startHour = startHour
|
||||||
|
if (endHour <= startHour) {
|
||||||
|
endHour = startHour + 1
|
||||||
|
reminderService.endHour = endHour
|
||||||
|
}
|
||||||
|
reminderService.reschedule()
|
||||||
},
|
},
|
||||||
valueRange = 0f..23f,
|
valueRange = 5f..23f,
|
||||||
steps = 22,
|
steps = 17,
|
||||||
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,41 +164,41 @@ fun ReminderSettingsView(onBack: () -> Unit) {
|
|||||||
onValueChange = {
|
onValueChange = {
|
||||||
endHour = it.toInt()
|
endHour = it.toInt()
|
||||||
reminderService.endHour = endHour
|
reminderService.endHour = endHour
|
||||||
|
reminderService.reschedule()
|
||||||
},
|
},
|
||||||
valueRange = 0f..23f,
|
valueRange = (startHour + 1).toFloat()..24f,
|
||||||
steps = 22,
|
steps = (24 - startHour - 2).coerceAtLeast(0),
|
||||||
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유동성 설정
|
// Flexible (toggle, matching iOS)
|
||||||
Card(
|
Card(
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Row(
|
||||||
Text("유동 스케줄링", fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
Text("배뇨 기록 시 다음 알림 시간을 자동으로 재설정합니다", fontSize = 12.sp, color = MlSecondaryText)
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text("유동 범위: ", fontSize = 13.sp, color = MlSecondaryText)
|
Text("유동 스케줄링", fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||||
Text("±${flexMinutes}분", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
Text("마지막 기록 기준으로 다음 알림을 조정합니다", fontSize = 12.sp, color = MlSecondaryText)
|
||||||
}
|
}
|
||||||
Slider(
|
Switch(
|
||||||
value = flexMinutes.toFloat(),
|
checked = isFlexible,
|
||||||
onValueChange = {
|
onCheckedChange = {
|
||||||
flexMinutes = it.toInt()
|
isFlexible = it
|
||||||
reminderService.flexMinutes = flexMinutes
|
reminderService.isFlexible = it
|
||||||
|
reminderService.reschedule()
|
||||||
},
|
},
|
||||||
valueRange = 0f..30f,
|
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
|
||||||
steps = 5,
|
|
||||||
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 알림 방식
|
// Alert methods (sound, vibration, popup)
|
||||||
Card(
|
Card(
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||||
@@ -190,6 +216,7 @@ fun ReminderSettingsView(onBack: () -> Unit) {
|
|||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
useSound = it
|
useSound = it
|
||||||
reminderService.useSound = it
|
reminderService.useSound = it
|
||||||
|
reminderService.reschedule()
|
||||||
},
|
},
|
||||||
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
|
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
|
||||||
)
|
)
|
||||||
@@ -204,6 +231,22 @@ fun ReminderSettingsView(onBack: () -> Unit) {
|
|||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
useVibration = it
|
useVibration = it
|
||||||
reminderService.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)
|
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
|
||||||
)
|
)
|
||||||
@@ -211,14 +254,50 @@ fun ReminderSettingsView(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 저장 & 적용
|
// Summary
|
||||||
Button(
|
Card(
|
||||||
onClick = { reminderService.reschedule() },
|
shape = RoundedCornerShape(16.dp),
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
|
||||||
) {
|
) {
|
||||||
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}시간"
|
h > 0 -> "${h}시간"
|
||||||
else -> "${m}분"
|
else -> "${m}분"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user