feat(locale): Phase 2 — 모든 사용자 노출 Korean 문자열 → strings.xml 추출 + 영문 번역

Sub-agent 작업 결과:
- values/strings.xml + values-ko/strings.xml 각각 71 → 194 줄 (약 85 신규 string)
- 17 파일 수정 (UI views 9개, components 3개, managers 1개, services 2개)

상세:
- ui/views/{home, consent, registration, reminder, monitoring (3), personalization, connection}
- components/{BatteryWarningBanner, FirmwareWarningBanner, LockableSlider}
- managers/AlignmentAdvisorV2 — Out/AdvisorState 에 msgKey + msgArgs 추가 (UI 가 context.getString 으로 localize)
- services/{ReminderService, BleForegroundService}

의도적으로 skip:
- ClinicalHomeView / ClinicalLiveView (R&D-only dev mode)
- PiezoMonitoring dev panel labels (dev 도구)
- AlignmentAdvice / 2-phase AlignGuide (UI 미사용)
- 이미 영어인 파일들 (Onboarding, Pin, SensorSelect, Calibration 등)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:27:05 +09:00
parent bc7843809e
commit 2cf4f4db2f
17 changed files with 409 additions and 110 deletions
@@ -18,6 +18,7 @@
*/ */
package com.example.medilightv2android.managers package com.example.medilightv2android.managers
import com.example.medilightv2android.R
import com.example.medilightv2android.walldetect.MethodDResult import com.example.medilightv2android.walldetect.MethodDResult
import com.example.medilightv2android.walldetect.MethodDRunner import com.example.medilightv2android.walldetect.MethodDRunner
import com.example.medilightv2android.walldetect.algo.methodd.MethodDParams import com.example.medilightv2android.walldetect.algo.methodd.MethodDParams
@@ -32,6 +33,10 @@ data class AdvisorState(
val state: String, // 'accum' 또는 'commit' val state: String, // 'accum' 또는 'commit'
val action: AlignAction?, val action: AlignAction?,
val msg: String, val msg: String,
/** Localized message string resource id (null if no localized message). */
val msgKey: Int? = null,
/** Args for msgKey (formatted into string). */
val msgArgs: List<Any> = emptyList(),
val dets: List<MethodDResult?>?, val dets: List<MethodDResult?>?,
val summary: AlignSummary?, val summary: AlignSummary?,
val accumProgress: Int = 0, // 'accum' 일 때 (현재/총) val accumProgress: Int = 0, // 'accum' 일 때 (현재/총)
@@ -161,7 +166,16 @@ class AlignGuide4Stage(
) { ) {
enum class Phase4 { INITIAL_ACCUM, VERTICAL_CLIMB, CH3_STABILIZE, LR_BALANCE } enum class Phase4 { INITIAL_ACCUM, VERTICAL_CLIMB, CH3_STABILIZE, LR_BALANCE }
data class Out(val action: AlignAction?, val msg: String, val phase: Phase4, val progress: Int = 0) data class Out(
val action: AlignAction?,
val msg: String,
val phase: Phase4,
val progress: Int = 0,
/** Localized string resource id for user-facing UI (msg is dev/debug). */
val msgKey: Int? = null,
/** Args for msgKey formatting. */
val msgArgs: List<Any> = emptyList(),
)
var phase: Phase4 = Phase4.INITIAL_ACCUM var phase: Phase4 = Phase4.INITIAL_ACCUM
private set private set
@@ -191,7 +205,9 @@ class AlignGuide4Stage(
initialCount++ initialCount++
if (initialCount < initialTarget) { if (initialCount < initialTarget) {
return Out(null, "잠시 그대로 두세요 (${initialCount}/${initialTarget})", return Out(null, "잠시 그대로 두세요 (${initialCount}/${initialTarget})",
phase, initialCount) phase, initialCount,
msgKey = R.string.align_hold_still,
msgArgs = listOf(initialCount, initialTarget))
} }
phase = Phase4.VERTICAL_CLIMB phase = Phase4.VERTICAL_CLIMB
} }
@@ -200,7 +216,8 @@ class AlignGuide4Stage(
if (phase == Phase4.VERTICAL_CLIMB) { if (phase == Phase4.VERTICAL_CLIMB) {
if (!s.ch3) { if (!s.ch3) {
return Out(AlignAction.MOVE_UP, "↑ 위로 조금씩 올리세요", return Out(AlignAction.MOVE_UP, "↑ 위로 조금씩 올리세요",
phase, 0) phase, 0,
msgKey = R.string.align_move_up)
} }
phase = Phase4.CH3_STABILIZE phase = Phase4.CH3_STABILIZE
stabilizeCount = 0 stabilizeCount = 0
@@ -216,12 +233,15 @@ class AlignGuide4Stage(
phase = Phase4.VERTICAL_CLIMB phase = Phase4.VERTICAL_CLIMB
stabilizeCount = 0 stabilizeCount = 0
return Out(AlignAction.MOVE_UP, "↑ 살짝 놓침 — 다시 위로", return Out(AlignAction.MOVE_UP, "↑ 살짝 놓침 — 다시 위로",
phase, 0) phase, 0,
msgKey = R.string.align_recover_up)
} }
if (stabilizeCount < stabilizeTarget) { if (stabilizeCount < stabilizeTarget) {
return Out(AlignAction.STOP, return Out(AlignAction.STOP,
"✓ 좋아요! 그 위치 유지 (${stabilizeCount}/${stabilizeTarget})", "✓ 좋아요! 그 위치 유지 (${stabilizeCount}/${stabilizeTarget})",
phase, stabilizeCount) phase, stabilizeCount,
msgKey = R.string.align_hold_position,
msgArgs = listOf(stabilizeCount, stabilizeTarget))
} }
phase = Phase4.LR_BALANCE phase = Phase4.LR_BALANCE
} }
@@ -235,7 +255,10 @@ class AlignGuide4Stage(
val recoveryDir = if (3 in lost) AlignAction.MOVE_UP else AlignAction.MOVE_DOWN val recoveryDir = if (3 in lost) AlignAction.MOVE_UP else AlignAction.MOVE_DOWN
val arrow = if (recoveryDir == AlignAction.MOVE_UP) "↑" else "↓" val arrow = if (recoveryDir == AlignAction.MOVE_UP) "↑" else "↓"
val dirWord = if (recoveryDir == AlignAction.MOVE_UP) "위" else "아래" val dirWord = if (recoveryDir == AlignAction.MOVE_UP) "위" else "아래"
return Out(recoveryDir, "$arrow 위치 살짝 벗어남 — ${dirWord}로 약간", phase, 0) val recoveryKey = if (recoveryDir == AlignAction.MOVE_UP)
R.string.align_recover_up_short else R.string.align_recover_down_short
return Out(recoveryDir, "$arrow 위치 살짝 벗어남 — ${dirWord}로 약간", phase, 0,
msgKey = recoveryKey)
} }
// full set 유지 중 — 좌우 균형 (deadband + consecutive 악화 로 oscillation 차단) // full set 유지 중 — 좌우 균형 (deadband + consecutive 악화 로 oscillation 차단)
@@ -245,7 +268,8 @@ class AlignGuide4Stage(
val imbal = kotlin.math.abs(u4 - u5) val imbal = kotlin.math.abs(u4 - u5)
if (imbal <= latTol) { if (imbal <= latTol) {
return Out(AlignAction.STOP, return Out(AlignAction.STOP,
"✓ 정렬 완료! 그대로 측정 가능", phase, 0) "✓ 정렬 완료! 그대로 측정 가능", phase, 0,
msgKey = R.string.align_complete)
} }
if (lateralDir == null) { if (lateralDir == null) {
// 첫 진입 — u4/u5 비교로 방향 추정 // 첫 진입 — u4/u5 비교로 방향 추정
@@ -270,10 +294,14 @@ class AlignGuide4Stage(
lateralImbal = imbal lateralImbal = imbal
val arrow = if (lateralDir == AlignAction.MOVE_LEFT) "←" else "→" val arrow = if (lateralDir == AlignAction.MOVE_LEFT) "←" else "→"
val sideWord = if (lateralDir == AlignAction.MOVE_LEFT) "왼쪽" else "오른쪽" val sideWord = if (lateralDir == AlignAction.MOVE_LEFT) "왼쪽" else "오른쪽"
return Out(lateralDir!!, "$arrow ${sideWord}으로 살짝", phase, 0) val sideKey = if (lateralDir == AlignAction.MOVE_LEFT)
R.string.align_left else R.string.align_right
return Out(lateralDir!!, "$arrow ${sideWord}으로 살짝", phase, 0,
msgKey = sideKey)
} }
// u4/u5 미검출 → probe // u4/u5 미검출 → probe
return Out(AlignAction.PROBE_LR, "↔ 좌우로 천천히 움직여 보세요", phase, 0) return Out(AlignAction.PROBE_LR, "↔ 좌우로 천천히 움직여 보세요", phase, 0,
msgKey = R.string.align_probe_lr)
} }
} }
@@ -329,6 +357,7 @@ class RollingAligner(
val state = if (out.phase == AlignGuide4Stage.Phase4.INITIAL_ACCUM) "accum" else "commit" val state = if (out.phase == AlignGuide4Stage.Phase4.INITIAL_ACCUM) "accum" else "commit"
val st = AdvisorState( val st = AdvisorState(
state = state, action = out.action, msg = out.msg, state = state, action = out.action, msg = out.msg,
msgKey = out.msgKey, msgArgs = out.msgArgs,
dets = dets, summary = summary, dets = dets, summary = summary,
accumProgress = if (state == "accum") out.progress else accumK, accumProgress = if (state == "accum") out.progress else accumK,
accumTarget = accumK, accumTarget = accumK,
@@ -108,7 +108,7 @@ class BleForegroundService : Service() {
"VesiScan BLE Connection", "VesiScan BLE Connection",
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW
).apply { ).apply {
description = "BLE 기기 연결 유지" description = getString(R.string.ble_service_channel_desc)
setShowBadge(false) setShowBadge(false)
} }
val nm = getSystemService(NotificationManager::class.java) val nm = getSystemService(NotificationManager::class.java)
@@ -127,7 +127,7 @@ class BleForegroundService : Service() {
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("VesiScan") .setContentTitle("VesiScan")
.setContentText("BLE 기기 연결 중...") .setContentText(getString(R.string.ble_service_connecting))
.setSmallIcon(R.drawable.ic_launcher_foreground) .setSmallIcon(R.drawable.ic_launcher_foreground)
.setOngoing(true) .setOngoing(true)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
@@ -29,7 +29,6 @@ class ReminderService private constructor(private val context: Context) {
} }
private const val CHANNEL_ID = "catheter_reminder" private const val CHANNEL_ID = "catheter_reminder"
private const val CHANNEL_NAME = "배뇨 알림"
private const val NOTIFICATION_ID = 2001 private const val NOTIFICATION_ID = 2001
private const val REQUEST_CODE = 3001 private const val REQUEST_CODE = 3001
private const val PREFS_NAME = "reminder_prefs" private const val PREFS_NAME = "reminder_prefs"
@@ -148,19 +147,19 @@ class ReminderService private constructor(private val context: Context) {
alarmManager.cancel(pendingIntent) alarmManager.cancel(pendingIntent)
} }
private val reminderMessages = listOf( private val reminderMessageIds = listOf(
"배뇨 시간입니다! 도뇨 준비를 해주세요.", R.string.reminder_msg_1,
"규칙적인 배뇨는 건강의 기본입니다!", R.string.reminder_msg_2,
"화장실에 다녀오세요! 💧", R.string.reminder_msg_3,
"배뇨 시간이에요. 건강한 습관을 유지하세요!", R.string.reminder_msg_4,
"잊지 마세요! 화장실 갈 시간입니다." R.string.reminder_msg_5,
) )
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("VesiScan 알림") .setContentTitle(context.getString(R.string.notification_title))
.setContentText(reminderMessages.random()) .setContentText(context.getString(reminderMessageIds.random()))
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true) .setAutoCancel(true)
@@ -198,9 +197,9 @@ class ReminderService private constructor(private val context: Context) {
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH CHANNEL_ID, context.getString(R.string.notification_channel_name), NotificationManager.IMPORTANCE_HIGH
).apply { ).apply {
description = "배뇨 알리미 알림" description = context.getString(R.string.notification_channel_desc)
enableVibration(true) enableVibration(true)
} }
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -18,9 +18,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.medilightv2android.R
import com.example.medilightv2android.ble.BleManager import com.example.medilightv2android.ble.BleManager
/** /**
@@ -41,11 +43,11 @@ fun BatteryWarningBanner(bleManager: BleManager, modifier: Modifier = Modifier)
val critical = batt <= CRITICAL_BATTERY_THRESHOLD val critical = batt <= CRITICAL_BATTERY_THRESHOLD
val bgColor = if (critical) Color(0xFFD32F2F) else Color(0xFFFF9500) val bgColor = if (critical) Color(0xFFD32F2F) else Color(0xFFFF9500)
val title = if (critical) "기기 배터리 위급" else "기기 배터리 부족" val title = if (critical) stringResource(R.string.battery_critical_title) else stringResource(R.string.battery_low_title)
val subtitle = if (critical) val subtitle = if (critical)
"현재 ${batt}% · 측정이 갑자기 중단될 수 있습니다. 즉시 충전하세요." stringResource(R.string.battery_critical_desc, batt)
else else
"현재 ${batt}% · 곧 충전이 필요합니다." stringResource(R.string.battery_low_desc, batt)
Row( Row(
modifier = modifier modifier = modifier
@@ -22,9 +22,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.medilightv2android.R
import com.example.medilightv2android.ble.BleManager import com.example.medilightv2android.ble.BleManager
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -75,11 +77,11 @@ fun FirmwareWarningBanner(bleManager: BleManager, modifier: Modifier = Modifier)
if (!outdated && !showTimedOut) return if (!outdated && !showTimedOut) return
val bgColor = if (outdated) Color(0xFFFF9500) else Color(0xFFD32F2F) val bgColor = if (outdated) Color(0xFFFF9500) else Color(0xFFD32F2F)
val title = if (outdated) "펌웨어 업데이트 필요" else "기기 응답 없음" val title = if (outdated) stringResource(R.string.firmware_outdated_title) else stringResource(R.string.firmware_no_response_title)
// timedOut(mid? 미수신) 케이스는 사용자에게 mid?/펌웨어 호환 같은 기술 용어가 무의미. // timedOut(mid? 미수신) 케이스는 사용자에게 mid?/펌웨어 호환 같은 기술 용어가 무의미.
// outdated 일 때만 상세 안내, timedOut 은 타이틀만 노출. // outdated 일 때만 상세 안내, timedOut 은 타이틀만 노출.
val subtitle: String? = if (outdated) { val subtitle: String? = if (outdated) {
"현재: $fw · 필요: VBTFW${"%04d".format(BleManager.MIN_FIRMWARE_BUILD)} 이상. 측정이 정상 동작하지 않을 수 있습니다." stringResource(R.string.firmware_outdated_desc, fw, BleManager.MIN_FIRMWARE_BUILD)
} else { } else {
null null
} }
@@ -37,7 +37,9 @@ import androidx.compose.runtime.setValue
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.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.medilightv2android.R
@Composable @Composable
fun LockableSlider( fun LockableSlider(
@@ -57,7 +59,7 @@ fun LockableSlider(
) { ) {
Icon( Icon(
if (unlocked) Icons.Default.LockOpen else Icons.Default.Lock, if (unlocked) Icons.Default.LockOpen else Icons.Default.Lock,
contentDescription = if (unlocked) "잠금 해제됨 — 다시 탭하여 잠금" else "잠김 — 탭하여 해제 후 조정", contentDescription = if (unlocked) stringResource(R.string.slider_unlocked_desc) else stringResource(R.string.slider_locked_desc),
tint = if (unlocked) Color(0xFF4CAF50) else Color.Gray.copy(alpha = 0.5f), tint = if (unlocked) Color(0xFF4CAF50) else Color.Gray.copy(alpha = 0.5f),
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
@@ -29,11 +29,13 @@ import androidx.compose.ui.draw.shadow
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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.example.medilightv2android.AppState import com.example.medilightv2android.AppState
import com.example.medilightv2android.R
import com.example.medilightv2android.ble.BleDevice import com.example.medilightv2android.ble.BleDevice
import com.example.medilightv2android.ble.BleManager import com.example.medilightv2android.ble.BleManager
import com.example.medilightv2android.models.SensorMode import com.example.medilightv2android.models.SensorMode
@@ -342,17 +344,17 @@ fun DeviceScanView(appState: AppState) {
pendingDelete?.let { target -> pendingDelete?.let { target ->
AlertDialog( AlertDialog(
onDismissRequest = { pendingDelete = null }, onDismissRequest = { pendingDelete = null },
title = { Text("페어링 해제") }, title = { Text(stringResource(R.string.unpair_title)) },
text = { Text("${target.name} 의 페어링을 해제합니다. 다시 사용하려면 PIN 재입력이 필요합니다.") }, text = { Text(stringResource(R.string.unpair_message, target.name)) },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
bleManager.unbondAndRemoveAddress(target.address) bleManager.unbondAndRemoveAddress(target.address)
knownDevices = KnownDeviceStore.getAll(context) knownDevices = KnownDeviceStore.getAll(context)
pendingDelete = null pendingDelete = null
}) { Text("해제", color = Color.Red) } }) { Text(stringResource(R.string.unpair_confirm), color = Color.Red) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text("취소") } TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
} }
) )
} }
@@ -13,7 +13,9 @@ import androidx.compose.ui.Modifier
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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import com.example.medilightv2android.R
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppState import com.example.medilightv2android.AppState
@@ -47,13 +49,13 @@ fun ConsentView(appState: AppState) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
"이용 동의", stringResource(R.string.consent_title),
fontSize = 24.sp, fontSize = 24.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 32.dp) modifier = Modifier.padding(horizontal = 32.dp)
) )
Text( Text(
"서비스 이용을 위해 아래 항목에 동의해 주세요.", stringResource(R.string.consent_intro),
fontSize = 14.sp, fontSize = 14.sp,
color = MlSecondaryText, color = MlSecondaryText,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp) modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp)
@@ -91,28 +93,28 @@ fun ConsentView(appState: AppState) {
colors = CheckboxDefaults.colors(checkedColor = MlPrimary) colors = CheckboxDefaults.colors(checkedColor = MlPrimary)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text("전체 동의", fontSize = 16.sp, fontWeight = FontWeight.Bold) Text(stringResource(R.string.consent_agree_all), fontSize = 16.sp, fontWeight = FontWeight.Bold)
} }
HorizontalDivider(color = MlSecondaryText.copy(alpha = 0.2f)) HorizontalDivider(color = MlSecondaryText.copy(alpha = 0.2f))
ConsentItem( ConsentItem(
title = "[필수] 개인정보 수집 및 이용 동의", title = stringResource(R.string.consent_personal_title),
description = "이름, 나이, 성별, 신체정보(키·체중)를 앱 내 기능 제공 목적으로 수집합니다. 수집된 정보는 기기에만 저장되며 외부로 전송되지 않습니다.", description = stringResource(R.string.consent_personal_desc),
checked = consentPersonal, checked = consentPersonal,
onCheckedChange = { consentPersonal = it } onCheckedChange = { consentPersonal = it }
) )
ConsentItem( ConsentItem(
title = "[필수] 의료정보 활용 동의", title = stringResource(R.string.consent_medical_title),
description = "방광 측정 데이터, 배뇨일지, NIRS 산소포화도 등 건강 관련 데이터를 모니터링 및 기록 목적으로 활용합니다.", description = stringResource(R.string.consent_medical_desc),
checked = consentMedical, checked = consentMedical,
onCheckedChange = { consentMedical = it } onCheckedChange = { consentMedical = it }
) )
ConsentItem( ConsentItem(
title = "[필수] 데이터 저장 동의", title = stringResource(R.string.consent_data_title),
description = "측정 기록 및 배뇨일지 데이터가 기기 내부 저장소에 저장됩니다. 앱 삭제 시 모든 데이터가 삭제됩니다.", description = stringResource(R.string.consent_data_desc),
checked = consentData, checked = consentData,
onCheckedChange = { consentData = it } onCheckedChange = { consentData = it }
) )
@@ -149,7 +151,7 @@ fun ConsentView(appState: AppState) {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
"동의하고 계속하기", stringResource(R.string.consent_continue),
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = Color.White color = Color.White
@@ -26,7 +26,9 @@ import androidx.compose.ui.draw.clip
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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import com.example.medilightv2android.R
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppState import com.example.medilightv2android.AppState
@@ -167,13 +169,13 @@ fun HomeView(appState: AppState) {
var selected by remember { mutableStateOf(curMode) } var selected by remember { mutableStateOf(curMode) }
AlertDialog( AlertDialog(
onDismissRequest = { showLanguageDialog = false }, onDismissRequest = { showLanguageDialog = false },
title = { Text("Language / 언어") }, title = { Text(stringResource(R.string.language_dialog_title)) },
text = { text = {
Column { Column {
listOf( listOf(
com.example.medilightv2android.services.LocalePref.Mode.SYSTEM to "시스템 기본 / System default", com.example.medilightv2android.services.LocalePref.Mode.SYSTEM to stringResource(R.string.language_system_default),
com.example.medilightv2android.services.LocalePref.Mode.KO to "한국어", com.example.medilightv2android.services.LocalePref.Mode.KO to stringResource(R.string.language_korean),
com.example.medilightv2android.services.LocalePref.Mode.EN to "English", com.example.medilightv2android.services.LocalePref.Mode.EN to stringResource(R.string.language_english),
).forEach { (mode, label) -> ).forEach { (mode, label) ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -198,10 +200,10 @@ fun HomeView(appState: AppState) {
showLanguageDialog = false showLanguageDialog = false
// Activity recreate → attachBaseContext 다시 호출 → 새 locale 적용 // Activity recreate → attachBaseContext 다시 호출 → 새 locale 적용
(context as? android.app.Activity)?.recreate() (context as? android.app.Activity)?.recreate()
}) { Text("OK") } }) { Text(stringResource(R.string.confirm)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showLanguageDialog = false }) { Text("Cancel") } TextButton(onClick = { showLanguageDialog = false }) { Text(stringResource(R.string.cancel)) }
} }
) )
} }
@@ -175,17 +175,17 @@ fun PiezoMonitoringView(appState: AppState) {
Icon(Icons.Default.Warning, contentDescription = null, Icon(Icons.Default.Warning, contentDescription = null,
tint = Color.White, modifier = Modifier.size(56.dp)) tint = Color.White, modifier = Modifier.size(56.dp))
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text("환자가 일어났습니다", fontSize = 28.sp, Text(stringResource(R.string.alarm_patient_woke_up), fontSize = 28.sp,
fontWeight = FontWeight.ExtraBold, color = Color.White, fontWeight = FontWeight.ExtraBold, color = Color.White,
textAlign = TextAlign.Center) textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Text("Patient is getting up", fontSize = 16.sp, Text(stringResource(R.string.alarm_patient_woke_up_en), fontSize = 16.sp,
fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = 0.9f), fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = 0.9f),
textAlign = TextAlign.Center) textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
val mins = wakeupAlarmElapsedSec / 60 val mins = wakeupAlarmElapsedSec / 60
val secs = wakeupAlarmElapsedSec % 60 val secs = wakeupAlarmElapsedSec % 60
Text("누운 시간 ${mins}분 ${secs}초", Text(stringResource(R.string.alarm_lying_duration, mins, secs),
fontSize = 14.sp, color = Color.White.copy(alpha = 0.85f)) fontSize = 14.sp, color = Color.White.copy(alpha = 0.85f))
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
@@ -196,7 +196,7 @@ fun PiezoMonitoringView(appState: AppState) {
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(14.dp) shape = RoundedCornerShape(14.dp)
) { ) {
Text("확인", fontSize = 20.sp, fontWeight = FontWeight.Bold) Text(stringResource(R.string.confirm), fontSize = 20.sp, fontWeight = FontWeight.Bold)
} }
} }
} }
@@ -223,16 +223,16 @@ fun PiezoMonitoringView(appState: AppState) {
Icon(Icons.Default.DirectionsWalk, contentDescription = null, Icon(Icons.Default.DirectionsWalk, contentDescription = null,
tint = Color.White, modifier = Modifier.size(64.dp)) tint = Color.White, modifier = Modifier.size(64.dp))
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text("환자가 걷고 있습니다", fontSize = 30.sp, Text(stringResource(R.string.alarm_patient_walking), fontSize = 30.sp,
fontWeight = FontWeight.ExtraBold, color = Color.White, fontWeight = FontWeight.ExtraBold, color = Color.White,
textAlign = TextAlign.Center) textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Text("Patient is walking — fall risk", Text(stringResource(R.string.alarm_patient_walking_en),
fontSize = 16.sp, fontWeight = FontWeight.Medium, fontSize = 16.sp, fontWeight = FontWeight.Medium,
color = Color.White.copy(alpha = 0.92f), color = Color.White.copy(alpha = 0.92f),
textAlign = TextAlign.Center) textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text("즉시 환자 상태 확인 필요", Text(stringResource(R.string.alarm_immediate_check),
fontSize = 14.sp, color = Color.White.copy(alpha = 0.9f), fontSize = 14.sp, color = Color.White.copy(alpha = 0.9f),
textAlign = TextAlign.Center) textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -244,7 +244,7 @@ fun PiezoMonitoringView(appState: AppState) {
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(14.dp) shape = RoundedCornerShape(14.dp)
) { ) {
Text("확인", fontSize = 20.sp, fontWeight = FontWeight.Bold) Text(stringResource(R.string.confirm), fontSize = 20.sp, fontWeight = FontWeight.Bold)
} }
} }
} }
@@ -856,7 +856,7 @@ fun PiezoMonitoringView(appState: AppState) {
) )
} }
// ��뇨 기록 입력 방식 선택 (iOS DiaryRecordSheet 스타일) // 배뇨 기록 입력 방식 선택 (iOS DiaryRecordSheet 스타일)
if (showCatheterizeSheet) { if (showCatheterizeSheet) {
AlertDialog( AlertDialog(
onDismissRequest = { showCatheterizeSheet = false }, onDismissRequest = { showCatheterizeSheet = false },
@@ -868,7 +868,7 @@ fun PiezoMonitoringView(appState: AppState) {
"${appState.estimatedVolume} mL", "${appState.estimatedVolume} mL",
fontSize = 28.sp, fontWeight = FontWeight.Bold, color = MlPrimary fontSize = 28.sp, fontWeight = FontWeight.Bold, color = MlPrimary
) )
Text("추정 용량", fontSize = 12.sp, color = MlSecondaryText) Text(stringResource(R.string.estimated_volume), fontSize = 12.sp, color = MlSecondaryText)
} }
}, },
text = { text = {
@@ -1926,8 +1926,8 @@ fun PiezoMonitoringView(appState: AppState) {
Row(verticalAlignment = Alignment.CenterVertically, Row(verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()) { modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text("기상 알람", fontSize = labelSize, fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.wakeup_alarm_title), fontSize = labelSize, fontWeight = FontWeight.SemiBold)
Text("일어남 감지 시 빨간 팝업", Text(stringResource(R.string.wakeup_alarm_desc),
fontSize = 11.sp, color = MlSecondaryText) fontSize = 11.sp, color = MlSecondaryText)
} }
Switch( Switch(
@@ -35,6 +35,7 @@ import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
@@ -45,6 +46,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppScreen import com.example.medilightv2android.AppScreen
import com.example.medilightv2android.AppState import com.example.medilightv2android.AppState
import com.example.medilightv2android.R
import com.example.medilightv2android.ble.BleManager import com.example.medilightv2android.ble.BleManager
import com.example.medilightv2android.ble.PiezoChannelData import com.example.medilightv2android.ble.PiezoChannelData
import com.example.medilightv2android.managers.DetachmentDetection import com.example.medilightv2android.managers.DetachmentDetection
@@ -202,15 +204,15 @@ fun PlacementGuideView(appState: AppState) {
Icon(Icons.Default.Warning, contentDescription = null, Icon(Icons.Default.Warning, contentDescription = null,
tint = Color.White, modifier = Modifier.size(56.dp)) tint = Color.White, modifier = Modifier.size(56.dp))
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text("센서가 분리되었습니다", fontSize = 26.sp, Text(stringResource(R.string.sensor_detached_title), fontSize = 26.sp,
fontWeight = FontWeight.ExtraBold, color = Color.White, fontWeight = FontWeight.ExtraBold, color = Color.White,
textAlign = TextAlign.Center) textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Text("Sensor detached", fontSize = 16.sp, Text(stringResource(R.string.sensor_detached_subtitle), fontSize = 16.sp,
fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = 0.9f), fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = 0.9f),
textAlign = TextAlign.Center) textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text("치골 위쪽에 다시 위치시켜 주세요", Text(stringResource(R.string.sensor_detached_desc),
fontSize = 14.sp, color = Color.White.copy(alpha = 0.85f), fontSize = 14.sp, color = Color.White.copy(alpha = 0.85f),
textAlign = TextAlign.Center) textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -222,7 +224,7 @@ fun PlacementGuideView(appState: AppState) {
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(14.dp) shape = RoundedCornerShape(14.dp)
) { ) {
Text("확인", fontSize = 20.sp, fontWeight = FontWeight.Bold) Text(stringResource(R.string.confirm), fontSize = 20.sp, fontWeight = FontWeight.Bold)
} }
} }
} }
@@ -353,7 +355,8 @@ fun PlacementGuideView(appState: AppState) {
// 점 ... 애니메이션 — 알고리즘 진행 중인 메시지에 점 추가. // 점 ... 애니메이션 — 알고리즘 진행 중인 메시지에 점 추가.
if (hint.startsWith("Slide") || hint.startsWith("Optimizing") || if (hint.startsWith("Slide") || hint.startsWith("Optimizing") ||
hint.startsWith("Checking") || hint.startsWith("Adjust") || hint.startsWith("Checking") || hint.startsWith("Adjust") ||
hint.contains("이동") || hint.contains("천천히") || hint.contains("그대로")) { hint.contains("이동") || hint.contains("천천히") || hint.contains("그대로") ||
hint.contains("Hold") || hint.contains("slowly") || hint.contains("Slightly")) {
append(dots) append(dots)
} }
if (appState.isDevMode && !lastCv.isNaN()) { if (appState.isDevMode && !lastCv.isNaN()) {
@@ -860,7 +863,7 @@ fun PlacementGuideView(appState: AppState) {
// Clinical alignment session + waitingForStart 일 땐 알고리즘 skip — // Clinical alignment session + waitingForStart 일 땐 알고리즘 skip —
// baseline cycles 만 누적 (Start 누르기 전 자동 phase 진행 차단). // baseline cycles 만 누적 (Start 누르기 전 자동 phase 진행 차단).
if (waitingForStart) { if (waitingForStart) {
directionHint = "센서를 치골 위에 두고 Start 를 누르세요" directionHint = context.getString(R.string.press_start_hint)
directionIcon = "" directionIcon = ""
placementScore = 0 placementScore = 0
} else { } else {
@@ -880,12 +883,16 @@ fun PlacementGuideView(appState: AppState) {
} }
} }
bleManager.debugLogger.info("ALIGN_V2 phase=${v2Aligner.phase} state=${st.state} action=${st.action} u4=${st.summary?.u4} u5=${st.summary?.u5} ch3=${st.summary?.ch3} bestSet=${v2Aligner.bestCenterSet}") bleManager.debugLogger.info("ALIGN_V2 phase=${v2Aligner.phase} state=${st.state} action=${st.action} u4=${st.summary?.u4} u5=${st.summary?.u5} ch3=${st.summary?.ch3} bestSet=${v2Aligner.bestCenterSet}")
// Localized message via string resource (falls back to st.msg for dev builds)
val localizedMsg = st.msgKey?.let { key ->
context.getString(key, *st.msgArgs.toTypedArray())
} ?: st.msg
if (st.state == "accum") { if (st.state == "accum") {
directionHint = st.msg directionHint = localizedMsg
directionIcon = "" directionIcon = ""
placementScore = 5 placementScore = 5
} else { } else {
directionHint = st.msg directionHint = localizedMsg
// STOP 액션은 두 경우 — CH3_STABILIZE 의 "위치 유지" (still in progress) vs // STOP 액션은 두 경우 — CH3_STABILIZE 의 "위치 유지" (still in progress) vs
// LR_BALANCE 의 "정렬 완료" (truly done). 후자만 finalStop 으로 GREEN lock. // LR_BALANCE 의 "정렬 완료" (truly done). 후자만 finalStop 으로 GREEN lock.
val finalStop = st.action == com.example.medilightv2android.managers.AlignAction.STOP && val finalStop = st.action == com.example.medilightv2android.managers.AlignAction.STOP &&
@@ -235,16 +235,16 @@ private fun SummaryBar(records: List<VoidingRecord>) {
) { ) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Default.WaterDrop, contentDescription = null, Modifier.size(14.dp), Color(0xFF2196F3)) Icon(Icons.Default.WaterDrop, contentDescription = null, Modifier.size(14.dp), Color(0xFF2196F3))
Text("${count}회", fontSize = 12.sp, color = Color(0xFF2196F3)) Text(stringResource(R.string.count_times, count), fontSize = 12.sp, color = Color(0xFF2196F3))
} }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Default.Science, contentDescription = null, Modifier.size(14.dp), Color(0xFF00BCD4)) Icon(Icons.Default.Science, contentDescription = null, Modifier.size(14.dp), Color(0xFF00BCD4))
Text("${total} ml", fontSize = 12.sp, color = Color(0xFF00BCD4)) Text(stringResource(R.string.volume_ml_short, total), fontSize = 12.sp, color = Color(0xFF00BCD4))
} }
if (count > 0) { if (count > 0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(Icons.Default.BarChart, contentDescription = null, Modifier.size(14.dp), Color(0xFF4CAF50)) Icon(Icons.Default.BarChart, contentDescription = null, Modifier.size(14.dp), Color(0xFF4CAF50))
Text("평균 ${avg} ml", fontSize = 12.sp, color = Color(0xFF4CAF50)) Text(stringResource(R.string.average_ml, avg), fontSize = 12.sp, color = Color(0xFF4CAF50))
} }
} }
} }
@@ -258,20 +258,20 @@ private fun MedicalMetricsCard(metrics: DiaryMetrics) {
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("24시간 의료 지표", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary) Text(stringResource(R.string.medical_metrics_24h), 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(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
MetricCell("24hUV", "${metrics.totalVolume24h} ml", Modifier.weight(1f)) MetricCell("24hUV", stringResource(R.string.volume_ml_short, metrics.totalVolume24h), Modifier.weight(1f))
MetricCell("NUV", "${metrics.nocturnalVolume} ml", Modifier.weight(1f)) MetricCell("NUV", stringResource(R.string.volume_ml_short, metrics.nocturnalVolume), 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)
MetricCell("Max", "${metrics.maxCapacity} ml", Modifier.weight(1f)) MetricCell("Max", stringResource(R.string.volume_ml_short, metrics.maxCapacity), Modifier.weight(1f))
} }
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
MetricCell("총 횟수", "${metrics.totalCount}회", Modifier.weight(1f)) MetricCell(stringResource(R.string.metric_total_count), stringResource(R.string.count_times, metrics.totalCount), Modifier.weight(1f))
MetricCell("주간", "${metrics.daytimeCount}회", Modifier.weight(1f)) MetricCell(stringResource(R.string.metric_daytime), stringResource(R.string.count_times, metrics.daytimeCount), Modifier.weight(1f))
MetricCell("야간", "${metrics.nighttimeCount}회", Modifier.weight(1f), MetricCell(stringResource(R.string.metric_nighttime), stringResource(R.string.count_times, metrics.nighttimeCount), Modifier.weight(1f),
highlight = metrics.nighttimeCount >= 2) highlight = metrics.nighttimeCount >= 2)
} }
} }
@@ -21,10 +21,12 @@ import androidx.compose.ui.draw.clip
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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppState import com.example.medilightv2android.AppState
import com.example.medilightv2android.R
import com.example.medilightv2android.ble.BleManager import com.example.medilightv2android.ble.BleManager
import com.example.medilightv2android.ui.components.BladdyIdleRiveView import com.example.medilightv2android.ui.components.BladdyIdleRiveView
import com.example.medilightv2android.ui.theme.* import com.example.medilightv2android.ui.theme.*
@@ -172,7 +174,7 @@ private fun CatheterThresholdCard(appState: AppState) {
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text("Voiding Reminder", fontSize = 28.sp, fontWeight = FontWeight.ExtraBold) Text("Voiding Reminder", fontSize = 28.sp, fontWeight = FontWeight.ExtraBold)
Text("방광이 이 정도 차오르면 알림", Text(stringResource(R.string.voiding_reminder_threshold_desc),
fontSize = 12.sp, color = MlSecondaryText, fontSize = 12.sp, color = MlSecondaryText,
modifier = Modifier.padding(top = 2.dp)) modifier = Modifier.padding(top = 2.dp))
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -354,13 +356,13 @@ private fun WakeupAlarmCard() {
modifier = androidx.compose.ui.Modifier.weight(1f) modifier = androidx.compose.ui.Modifier.weight(1f)
) { ) {
androidx.compose.material3.Text( androidx.compose.material3.Text(
"기상 알람", fontSize = 24.sp, fontWeight = FontWeight.ExtraBold stringResource(R.string.wakeup_alarm_title), fontSize = 24.sp, fontWeight = FontWeight.ExtraBold
) )
androidx.compose.foundation.layout.Spacer( androidx.compose.foundation.layout.Spacer(
modifier = androidx.compose.ui.Modifier.height(4.dp) modifier = androidx.compose.ui.Modifier.height(4.dp)
) )
androidx.compose.material3.Text( androidx.compose.material3.Text(
"환자가 일어나면 빨간 팝업으로 알림 (낙상 예방)", stringResource(R.string.wakeup_alarm_desc),
fontSize = 12.sp, color = MlSecondaryText fontSize = 12.sp, color = MlSecondaryText
) )
} }
@@ -13,11 +13,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppState import com.example.medilightv2android.AppState
import com.example.medilightv2android.R
import com.example.medilightv2android.models.Sex import com.example.medilightv2android.models.Sex
import com.example.medilightv2android.ui.components.BladdyIdleRiveView import com.example.medilightv2android.ui.components.BladdyIdleRiveView
import com.example.medilightv2android.ui.theme.* import com.example.medilightv2android.ui.theme.*
@@ -72,7 +74,7 @@ fun RegistrationView(appState: AppState) {
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
RegistrationTextField("Age", ageText, { ageText = it }, KeyboardType.Number) RegistrationTextField("Age", ageText, { ageText = it }, KeyboardType.Number)
} }
listOf(Sex.MALE to "남성", Sex.FEMALE to "여성").forEach { (sex, label) -> listOf(Sex.MALE to stringResource(R.string.sex_male), Sex.FEMALE to stringResource(R.string.sex_female)).forEach { (sex, label) ->
val selected = selectedSex == sex val selected = selectedSex == sex
OutlinedButton( OutlinedButton(
onClick = { selectedSex = sex }, onClick = { selectedSex = sex },
@@ -16,9 +16,11 @@ 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.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.medilightv2android.R
import com.example.medilightv2android.services.ReminderService import com.example.medilightv2android.services.ReminderService
import com.example.medilightv2android.ui.components.LockableSlider import com.example.medilightv2android.ui.components.LockableSlider
import com.example.medilightv2android.ui.theme.* import com.example.medilightv2android.ui.theme.*
@@ -43,10 +45,10 @@ fun ReminderSettingsView(onBack: () -> Unit) {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("배뇨 알리미", fontWeight = FontWeight.SemiBold) }, title = { Text(stringResource(R.string.reminder_title), fontWeight = FontWeight.SemiBold) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
} }
} }
) )
@@ -70,10 +72,10 @@ fun ReminderSettingsView(onBack: () -> Unit) {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text("알림 활성화", fontSize = 16.sp, fontWeight = FontWeight.Bold) Text(stringResource(R.string.reminder_enable), fontSize = 16.sp, fontWeight = FontWeight.Bold)
Text( Text(
if (isEnabled) "정해진 간격으로 배뇨 알림을 받습니다" if (isEnabled) stringResource(R.string.reminder_enabled_desc)
else "알림이 꺼져 있습니다", else stringResource(R.string.reminder_disabled_desc),
fontSize = 12.sp, color = MlSecondaryText fontSize = 12.sp, color = MlSecondaryText
) )
} }
@@ -95,7 +97,7 @@ fun ReminderSettingsView(onBack: () -> Unit) {
colors = CardDefaults.cardColors(containerColor = MlCardBackground) colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text("알림 간격", fontSize = 14.sp, fontWeight = FontWeight.Bold) Text(stringResource(R.string.reminder_interval_label), fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
intervalOptions.forEach { minutes -> intervalOptions.forEach { minutes ->
Row( Row(
@@ -110,7 +112,7 @@ fun ReminderSettingsView(onBack: () -> Unit) {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
formatInterval(minutes), formatInterval(minutes, context),
fontSize = 15.sp, fontSize = 15.sp,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
@@ -131,12 +133,12 @@ fun ReminderSettingsView(onBack: () -> Unit) {
colors = CardDefaults.cardColors(containerColor = MlCardBackground) colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text("알림 시간대", fontSize = 14.sp, fontWeight = FontWeight.Bold) Text(stringResource(R.string.reminder_active_hours), fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("시작", fontSize = 13.sp, color = MlSecondaryText, modifier = Modifier.width(40.dp)) Text(stringResource(R.string.reminder_start), fontSize = 13.sp, color = MlSecondaryText, modifier = Modifier.width(40.dp))
Text("${startHour}시", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MlPrimary) Text(stringResource(R.string.reminder_hour_format, startHour), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
} }
LockableSlider( LockableSlider(
value = startHour.toFloat(), value = startHour.toFloat(),
@@ -157,8 +159,8 @@ fun ReminderSettingsView(onBack: () -> Unit) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("종료", fontSize = 13.sp, color = MlSecondaryText, modifier = Modifier.width(40.dp)) Text(stringResource(R.string.reminder_end), fontSize = 13.sp, color = MlSecondaryText, modifier = Modifier.width(40.dp))
Text("${endHour}시", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MlPrimary) Text(stringResource(R.string.reminder_hour_format, endHour), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
} }
LockableSlider( LockableSlider(
value = endHour.toFloat(), value = endHour.toFloat(),
@@ -184,8 +186,8 @@ fun ReminderSettingsView(onBack: () -> Unit) {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text("유동 스케줄링", fontSize = 14.sp, fontWeight = FontWeight.Bold) Text(stringResource(R.string.reminder_flexible_title), fontSize = 14.sp, fontWeight = FontWeight.Bold)
Text("마지막 기록 기준으로 다음 알림을 조정합니다", fontSize = 12.sp, color = MlSecondaryText) Text(stringResource(R.string.reminder_flexible_desc), fontSize = 12.sp, color = MlSecondaryText)
} }
Switch( Switch(
checked = isFlexible, checked = isFlexible,
@@ -205,13 +207,13 @@ fun ReminderSettingsView(onBack: () -> Unit) {
colors = CardDefaults.cardColors(containerColor = MlCardBackground) colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text("알림 방식", fontSize = 14.sp, fontWeight = FontWeight.Bold) Text(stringResource(R.string.reminder_method_label), fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("소리", modifier = Modifier.weight(1f)) Text(stringResource(R.string.reminder_sound), modifier = Modifier.weight(1f))
Switch( Switch(
checked = useSound, checked = useSound,
onCheckedChange = { onCheckedChange = {
@@ -226,7 +228,7 @@ fun ReminderSettingsView(onBack: () -> Unit) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("진동", modifier = Modifier.weight(1f)) Text(stringResource(R.string.reminder_vibration), modifier = Modifier.weight(1f))
Switch( Switch(
checked = useVibration, checked = useVibration,
onCheckedChange = { onCheckedChange = {
@@ -241,7 +243,7 @@ fun ReminderSettingsView(onBack: () -> Unit) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("팝업", modifier = Modifier.weight(1f)) Text(stringResource(R.string.reminder_popup), modifier = Modifier.weight(1f))
Switch( Switch(
checked = usePopup, checked = usePopup,
onCheckedChange = { onCheckedChange = {
@@ -261,14 +263,14 @@ fun ReminderSettingsView(onBack: () -> Unit) {
colors = CardDefaults.cardColors(containerColor = MlCardBackground) colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) { ) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("요약", fontSize = 14.sp, fontWeight = FontWeight.Bold) Text(stringResource(R.string.reminder_summary), fontSize = 14.sp, fontWeight = FontWeight.Bold)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("🕐", fontSize = 14.sp) Text("🕐", fontSize = 14.sp)
Text("활성 시간: ${startHour}시 ~ ${endHour}시", fontSize = 14.sp, color = MlPrimary) Text(stringResource(R.string.reminder_active_hours_summary, startHour, endHour), fontSize = 14.sp, color = MlPrimary)
} }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("🔁", fontSize = 14.sp) Text("🔁", fontSize = 14.sp)
Text("매 ${formatInterval(intervalMinutes)} 간격", fontSize = 14.sp, color = Color(0xFFF5A623)) Text(stringResource(R.string.reminder_interval_summary, formatInterval(intervalMinutes, context)), fontSize = 14.sp, color = Color(0xFFF5A623))
} }
} }
} }
@@ -287,8 +289,8 @@ fun ReminderSettingsView(onBack: () -> Unit) {
Text("⚠️", fontSize = 20.sp) Text("⚠️", fontSize = 20.sp)
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text("알림 권한 필요", fontSize = 14.sp, fontWeight = FontWeight.Medium) Text(stringResource(R.string.reminder_permission_needed), fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text("설정에서 알림 권한을 허용해주세요", fontSize = 12.sp, color = MlSecondaryText) Text(stringResource(R.string.reminder_permission_desc), fontSize = 12.sp, color = MlSecondaryText)
} }
OutlinedButton(onClick = { OutlinedButton(onClick = {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
@@ -296,7 +298,7 @@ fun ReminderSettingsView(onBack: () -> Unit) {
} }
context.startActivity(intent) context.startActivity(intent)
}) { }) {
Text("설정") Text(stringResource(R.string.settings))
} }
} }
} }
@@ -307,12 +309,12 @@ fun ReminderSettingsView(onBack: () -> Unit) {
} }
} }
private fun formatInterval(minutes: Int): String { private fun formatInterval(minutes: Int, context: android.content.Context): String {
val h = minutes / 60 val h = minutes / 60
val m = minutes % 60 val m = minutes % 60
return when { return when {
h > 0 && m > 0 -> "${h}시간 ${m}분" h > 0 && m > 0 -> context.getString(R.string.format_hours_minutes, h, m)
h > 0 -> "${h}시간" h > 0 -> context.getString(R.string.format_hours, h)
else -> "${m}분" else -> context.getString(R.string.format_minutes, m)
} }
} }
+123
View File
@@ -68,4 +68,127 @@
<string name="too_dark">이미지가 너무 어둡습니다</string> <string name="too_dark">이미지가 너무 어둡습니다</string>
<string name="not_enough_marks">눈금 부족</string> <string name="not_enough_marks">눈금 부족</string>
<string name="detection_fail">감지 실패</string> <string name="detection_fail">감지 실패</string>
<!-- Home / Language Dialog -->
<string name="language_dialog_title">언어</string>
<string name="language_system_default">시스템 기본</string>
<string name="language_korean">한국어</string>
<string name="language_english">English</string>
<!-- Consent -->
<string name="consent_title">이용 동의</string>
<string name="consent_intro">서비스 이용을 위해 아래 항목에 동의해 주세요.</string>
<string name="consent_agree_all">전체 동의</string>
<string name="consent_personal_title">[필수] 개인정보 수집 및 이용 동의</string>
<string name="consent_personal_desc">이름, 나이, 성별, 신체정보(키·체중)를 앱 내 기능 제공 목적으로 수집합니다. 수집된 정보는 기기에만 저장되며 외부로 전송되지 않습니다.</string>
<string name="consent_medical_title">[필수] 의료정보 활용 동의</string>
<string name="consent_medical_desc">방광 측정 데이터, 배뇨일지, NIRS 산소포화도 등 건강 관련 데이터를 모니터링 및 기록 목적으로 활용합니다.</string>
<string name="consent_data_title">[필수] 데이터 저장 동의</string>
<string name="consent_data_desc">측정 기록 및 배뇨일지 데이터가 기기 내부 저장소에 저장됩니다. 앱 삭제 시 모든 데이터가 삭제됩니다.</string>
<string name="consent_continue">동의하고 계속하기</string>
<!-- Registration -->
<string name="sex_male">남성</string>
<string name="sex_female">여성</string>
<!-- Reminder Settings -->
<string name="reminder_title">배뇨 알리미</string>
<string name="reminder_enable">알림 활성화</string>
<string name="reminder_enabled_desc">정해진 간격으로 배뇨 알림을 받습니다</string>
<string name="reminder_disabled_desc">알림이 꺼져 있습니다</string>
<string name="reminder_interval_label">알림 간격</string>
<string name="reminder_active_hours">알림 시간대</string>
<string name="reminder_start">시작</string>
<string name="reminder_end">종료</string>
<string name="reminder_hour_format">%d시</string>
<string name="reminder_flexible_title">유동 스케줄링</string>
<string name="reminder_flexible_desc">마지막 기록 기준으로 다음 알림을 조정합니다</string>
<string name="reminder_method_label">알림 방식</string>
<string name="reminder_sound">소리</string>
<string name="reminder_vibration">진동</string>
<string name="reminder_popup">팝업</string>
<string name="reminder_summary">요약</string>
<string name="reminder_active_hours_summary">활성 시간: %1$d시 ~ %2$d시</string>
<string name="reminder_interval_summary">매 %s 간격</string>
<string name="reminder_permission_needed">알림 권한 필요</string>
<string name="reminder_permission_desc">설정에서 알림 권한을 허용해주세요</string>
<string name="settings">설정</string>
<string name="format_hours_minutes">%1$d시간 %2$d분</string>
<string name="format_hours">%d시간</string>
<string name="format_minutes">%d분</string>
<!-- Voiding Diary additions -->
<string name="average_ml">평균 %d ml</string>
<string name="count_times">%d회</string>
<string name="volume_ml_short">%d ml</string>
<string name="medical_metrics_24h">24시간 의료 지표</string>
<string name="metric_total_count">총 횟수</string>
<string name="metric_daytime">주간</string>
<string name="metric_nighttime">야간</string>
<!-- Placement Guide / Sensor Detach -->
<string name="sensor_detached_title">센서가 분리되었습니다</string>
<string name="sensor_detached_subtitle">Sensor detached</string>
<string name="sensor_detached_desc">치골 위쪽에 다시 위치시켜 주세요</string>
<string name="press_start_hint">센서를 치골 위에 두고 Start 를 누르세요</string>
<!-- Wakeup / Walking Alarm -->
<string name="alarm_patient_woke_up">환자가 일어났습니다</string>
<string name="alarm_patient_woke_up_en">Patient is getting up</string>
<string name="alarm_lying_duration">누운 시간 %1$d분 %2$d초</string>
<string name="alarm_patient_walking">환자가 걷고 있습니다</string>
<string name="alarm_patient_walking_en">Patient is walking — fall risk</string>
<string name="alarm_immediate_check">즉시 환자 상태 확인 필요</string>
<!-- Voiding Record additions -->
<string name="estimated_volume">추정 용량</string>
<!-- Device Scan -->
<string name="unpair_title">페어링 해제</string>
<string name="unpair_message">%s 의 페어링을 해제합니다. 다시 사용하려면 PIN 재입력이 필요합니다.</string>
<string name="unpair_confirm">해제</string>
<!-- Reminder Notifications -->
<string name="notification_channel_name">배뇨 알림</string>
<string name="notification_channel_desc">배뇨 알리미 알림</string>
<string name="notification_title">VesiScan 알림</string>
<string name="reminder_msg_1">배뇨 시간입니다! 도뇨 준비를 해주세요.</string>
<string name="reminder_msg_2">규칙적인 배뇨는 건강의 기본입니다!</string>
<string name="reminder_msg_3">화장실에 다녀오세요! 💧</string>
<string name="reminder_msg_4">배뇨 시간이에요. 건강한 습관을 유지하세요!</string>
<string name="reminder_msg_5">잊지 마세요! 화장실 갈 시간입니다.</string>
<!-- BLE Foreground Service -->
<string name="ble_service_channel_desc">BLE 기기 연결 유지</string>
<string name="ble_service_connecting">BLE 기기 연결 중...</string>
<!-- Battery / Firmware Warnings -->
<string name="battery_critical_title">기기 배터리 위급</string>
<string name="battery_low_title">기기 배터리 부족</string>
<string name="battery_critical_desc">현재 %d%% · 측정이 갑자기 중단될 수 있습니다. 즉시 충전하세요.</string>
<string name="battery_low_desc">현재 %d%% · 곧 충전이 필요합니다.</string>
<string name="firmware_outdated_title">펌웨어 업데이트 필요</string>
<string name="firmware_no_response_title">기기 응답 없음</string>
<string name="firmware_outdated_desc">현재: %1$s · 필요: VBTFW%2$04d 이상. 측정이 정상 동작하지 않을 수 있습니다.</string>
<!-- Lockable Slider -->
<string name="slider_unlocked_desc">잠금 해제됨 — 다시 탭하여 잠금</string>
<string name="slider_locked_desc">잠김 — 탭하여 해제 후 조정</string>
<!-- Personalization -->
<string name="wakeup_alarm_title">기상 알람</string>
<string name="wakeup_alarm_desc">환자가 일어나면 빨간 팝업으로 알림 (낙상 예방)</string>
<string name="voiding_reminder_threshold_desc">방광이 이 정도 차오르면 알림</string>
<!-- Alignment Advisor V2 hints -->
<string name="align_hold_still">잠시 그대로 두세요 (%1$d/%2$d)</string>
<string name="align_move_up">↑ 위로 조금씩 올리세요</string>
<string name="align_recover_up">↑ 살짝 놓침 — 다시 위로</string>
<string name="align_hold_position">✓ 좋아요! 그 위치 유지 (%1$d/%2$d)</string>
<string name="align_recover_up_short">↑ 위치 살짝 벗어남 — 위로 약간</string>
<string name="align_recover_down_short">↓ 위치 살짝 벗어남 — 아래로 약간</string>
<string name="align_complete">✓ 정렬 완료! 그대로 측정 가능</string>
<string name="align_left">← 왼쪽으로 살짝</string>
<string name="align_right">→ 오른쪽으로 살짝</string>
<string name="align_probe_lr">↔ 좌우로 천천히 움직여 보세요</string>
</resources> </resources>
+123
View File
@@ -68,4 +68,127 @@
<string name="too_dark">Image is too dark</string> <string name="too_dark">Image is too dark</string>
<string name="not_enough_marks">Not enough marks</string> <string name="not_enough_marks">Not enough marks</string>
<string name="detection_fail">Detection failed</string> <string name="detection_fail">Detection failed</string>
<!-- Home / Language Dialog -->
<string name="language_dialog_title">Language</string>
<string name="language_system_default">System default</string>
<string name="language_korean">Korean</string>
<string name="language_english">English</string>
<!-- Consent -->
<string name="consent_title">User Agreement</string>
<string name="consent_intro">Please agree to the items below to use this service.</string>
<string name="consent_agree_all">Agree to all</string>
<string name="consent_personal_title">[Required] Personal information collection</string>
<string name="consent_personal_desc">We collect name, age, sex, and body information (height, weight) for in-app functionality. The collected data is stored only on the device and is not transmitted externally.</string>
<string name="consent_medical_title">[Required] Medical data use</string>
<string name="consent_medical_desc">Bladder measurement data, voiding diary, NIRS oxygen saturation, and other health-related data are used for monitoring and record purposes.</string>
<string name="consent_data_title">[Required] Data storage</string>
<string name="consent_data_desc">Measurement records and voiding diary data are stored in the device internal storage. All data is deleted when the app is uninstalled.</string>
<string name="consent_continue">Agree and continue</string>
<!-- Registration -->
<string name="sex_male">Male</string>
<string name="sex_female">Female</string>
<!-- Reminder Settings -->
<string name="reminder_title">Voiding Reminder</string>
<string name="reminder_enable">Enable reminders</string>
<string name="reminder_enabled_desc">You will receive voiding reminders at the set interval</string>
<string name="reminder_disabled_desc">Reminders are turned off</string>
<string name="reminder_interval_label">Reminder interval</string>
<string name="reminder_active_hours">Active hours</string>
<string name="reminder_start">Start</string>
<string name="reminder_end">End</string>
<string name="reminder_hour_format">%d:00</string>
<string name="reminder_flexible_title">Flexible scheduling</string>
<string name="reminder_flexible_desc">Adjusts the next reminder based on the most recent record</string>
<string name="reminder_method_label">Notification method</string>
<string name="reminder_sound">Sound</string>
<string name="reminder_vibration">Vibration</string>
<string name="reminder_popup">Popup</string>
<string name="reminder_summary">Summary</string>
<string name="reminder_active_hours_summary">Active: %1$d:00 – %2$d:00</string>
<string name="reminder_interval_summary">Every %s</string>
<string name="reminder_permission_needed">Notification permission required</string>
<string name="reminder_permission_desc">Please allow notifications in Settings</string>
<string name="settings">Settings</string>
<string name="format_hours_minutes">%1$dh %2$dm</string>
<string name="format_hours">%dh</string>
<string name="format_minutes">%dm</string>
<!-- Voiding Diary additions -->
<string name="average_ml">Avg %d ml</string>
<string name="count_times">%d times</string>
<string name="volume_ml_short">%d ml</string>
<string name="medical_metrics_24h">24-hour Medical Metrics</string>
<string name="metric_total_count">Total</string>
<string name="metric_daytime">Day</string>
<string name="metric_nighttime">Night</string>
<!-- Placement Guide / Sensor Detach -->
<string name="sensor_detached_title">Sensor detached</string>
<string name="sensor_detached_subtitle">Sensor detached</string>
<string name="sensor_detached_desc">Please reposition above the pubic bone</string>
<string name="press_start_hint">Place the sensor above the pubic bone and press Start</string>
<!-- Wakeup / Walking Alarm -->
<string name="alarm_patient_woke_up">Patient is getting up</string>
<string name="alarm_patient_woke_up_en">Patient is getting up</string>
<string name="alarm_lying_duration">Lying for %1$d min %2$d sec</string>
<string name="alarm_patient_walking">Patient is walking</string>
<string name="alarm_patient_walking_en">Patient is walking — fall risk</string>
<string name="alarm_immediate_check">Immediate patient check required</string>
<!-- Voiding Record additions -->
<string name="estimated_volume">Estimated volume</string>
<!-- Device Scan -->
<string name="unpair_title">Unpair Device</string>
<string name="unpair_message">Unpair %s. PIN re-entry will be required to use it again.</string>
<string name="unpair_confirm">Unpair</string>
<!-- Reminder Notifications -->
<string name="notification_channel_name">Voiding reminder</string>
<string name="notification_channel_desc">Voiding reminder notifications</string>
<string name="notification_title">VesiScan reminder</string>
<string name="reminder_msg_1">Time to void! Please prepare for catheterization.</string>
<string name="reminder_msg_2">Regular voiding is fundamental to health!</string>
<string name="reminder_msg_3">Time for the bathroom! 💧</string>
<string name="reminder_msg_4">It is time to void. Keep up healthy habits!</string>
<string name="reminder_msg_5">Don\'t forget! It is time to go to the bathroom.</string>
<!-- BLE Foreground Service -->
<string name="ble_service_channel_desc">Maintains BLE device connection</string>
<string name="ble_service_connecting">Connecting to BLE device...</string>
<!-- Battery / Firmware Warnings -->
<string name="battery_critical_title">Critical battery</string>
<string name="battery_low_title">Low battery</string>
<string name="battery_critical_desc">%d%% · Measurement may stop suddenly. Charge immediately.</string>
<string name="battery_low_desc">%d%% · Please charge soon.</string>
<string name="firmware_outdated_title">Firmware update required</string>
<string name="firmware_no_response_title">No response from device</string>
<string name="firmware_outdated_desc">Current: %1$s · Required: VBTFW%2$04d or higher. Measurement may not work properly.</string>
<!-- Lockable Slider -->
<string name="slider_unlocked_desc">Unlocked — tap again to lock</string>
<string name="slider_locked_desc">Locked — tap to unlock and adjust</string>
<!-- Personalization -->
<string name="wakeup_alarm_title">Wake-up alarm</string>
<string name="wakeup_alarm_desc">Red popup when patient gets up (fall prevention)</string>
<string name="voiding_reminder_threshold_desc">Alert when bladder fills to this level</string>
<!-- Alignment Advisor V2 hints -->
<string name="align_hold_still">Hold still… (%1$d/%2$d)</string>
<string name="align_move_up">↑ Slide up slowly</string>
<string name="align_recover_up">↑ Lost it — slide back up</string>
<string name="align_hold_position">✓ Good! Keep this position (%1$d/%2$d)</string>
<string name="align_recover_up_short">↑ Slightly off — move up a bit</string>
<string name="align_recover_down_short">↓ Slightly off — move down a bit</string>
<string name="align_complete">✓ Aligned! Ready to measure</string>
<string name="align_left">← Slightly to the left</string>
<string name="align_right">→ Slightly to the right</string>
<string name="align_probe_lr">↔ Move slowly side to side</string>
</resources> </resources>