i18n: add Korean/English localization via strings.xml

Replace hardcoded Korean UI strings in Piezo, CatheterizeSheet,
VoidingDiary, and UrineCameraScreen with stringResource() calls.
Add values/strings.xml (English) and values-ko/strings.xml (Korean).
System language setting now controls app language.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dwjang
2026-04-13 11:23:58 +09:00
parent 0e186234d2
commit dc61e1d52e
6 changed files with 219 additions and 74 deletions
@@ -14,12 +14,14 @@ 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.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
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.R
import com.example.medilightv2android.measure.VoidingRecord import com.example.medilightv2android.measure.VoidingRecord
import com.example.medilightv2android.measure.VoidingRecordStore import com.example.medilightv2android.measure.VoidingRecordStore
import com.example.medilightv2android.speech.SpeechRecognizerManager import com.example.medilightv2android.speech.SpeechRecognizerManager
@@ -56,13 +58,13 @@ fun CatheterizeSheet(
VoidingRecordStore.add(context, VoidingRecord(volume = volume, method = method)) VoidingRecordStore.add(context, VoidingRecord(volume = volume, method = method))
onCatheterized() onCatheterized()
isSaved = true isSaved = true
Toast.makeText(context, "배뇨일지에 등록되었습니다", Toast.LENGTH_SHORT).show() Toast.makeText(context, context.getString(R.string.registered_diary), Toast.LENGTH_SHORT).show()
} }
fun startVoice() { fun startVoice() {
if (!hasAudioPerm) { audioPermLauncher.launch(android.Manifest.permission.RECORD_AUDIO); return } if (!hasAudioPerm) { audioPermLauncher.launch(android.Manifest.permission.RECORD_AUDIO); return }
speechManager?.destroy() speechManager?.destroy()
isListening = true; voiceStatus = "듣는 중..." isListening = true; voiceStatus = context.getString(R.string.listening)
speechManager = SpeechRecognizerManager( speechManager = SpeechRecognizerManager(
context = context, context = context,
onResult = { candidates -> onResult = { candidates ->
@@ -85,14 +87,14 @@ fun CatheterizeSheet(
volumeText = it.volumeMl.toString() volumeText = it.volumeMl.toString()
voiceStatus = "\"${it.rawText}\" → ${it.volumeMl} ml" voiceStatus = "\"${it.rawText}\" → ${it.volumeMl} ml"
saveRecord(it.volumeMl, "voice") saveRecord(it.volumeMl, "voice")
} ?: run { voiceStatus = "인식 실패: ${candidates.firstOrNull() ?: ""}" } } ?: run { voiceStatus = context.getString(R.string.recognition_fail, candidates.firstOrNull() ?: "") }
}, },
onError = { code -> isListening = false; voiceStatus = when(code) { onError = { code -> isListening = false; voiceStatus = when(code) {
android.speech.SpeechRecognizer.ERROR_NO_MATCH -> "인식된 음성 없음" android.speech.SpeechRecognizer.ERROR_NO_MATCH -> context.getString(R.string.recognition_fail, "")
android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "시간 초과" android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> context.getString(R.string.error_code, code)
else -> "오류 ($code)" else -> context.getString(R.string.error_code, code)
}}, }},
onReady = { voiceStatus = "말씀하세요..." } onReady = { voiceStatus = context.getString(R.string.speak_now) }
) )
speechManager?.startListening() speechManager?.startListening()
} }
@@ -106,7 +108,7 @@ fun CatheterizeSheet(
// 저장 완료 화면 // 저장 완료 화면
Icon(Icons.Default.CheckCircle, null, Modifier.size(64.dp), tint = Color(0xFF4CAF50)) Icon(Icons.Default.CheckCircle, null, Modifier.size(64.dp), tint = Color(0xFF4CAF50))
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("배뇨일지에 등록되었습니다", fontSize = 18.sp, fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.registered_diary), fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
if (volumeText.isNotEmpty()) { if (volumeText.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("$volumeText ml", fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color(0xFF007AFF)) Text("$volumeText ml", fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color(0xFF007AFF))
@@ -116,10 +118,10 @@ fun CatheterizeSheet(
onClick = onDismiss, onClick = onDismiss,
Modifier.fillMaxWidth().height(48.dp), Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { Text("확인") } ) { Text(stringResource(R.string.confirm)) }
} else { } else {
// 입력 모드 선택 // 입력 모드 선택
Text("배뇨 기록", fontSize = 20.sp, fontWeight = FontWeight.Bold) Text(stringResource(R.string.voiding_record), fontSize = 20.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// 3 탭 버튼 // 3 탭 버튼
@@ -135,7 +137,7 @@ fun CatheterizeSheet(
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Edit, null, Modifier.size(20.dp), tint = if (selectedTab == 0) Color.White else Color(0xFFFF9800)) Icon(Icons.Default.Edit, null, Modifier.size(20.dp), tint = if (selectedTab == 0) Color.White else Color(0xFFFF9800))
Text("수기", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = if (selectedTab == 0) Color.White else Color(0xFFFF9800)) Text(stringResource(R.string.manual_input), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = if (selectedTab == 0) Color.White else Color(0xFFFF9800))
} }
} }
// 음성 // 음성
@@ -149,7 +151,7 @@ fun CatheterizeSheet(
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.SettingsVoice, null, Modifier.size(20.dp), tint = if (selectedTab == 1) Color.White else Color(0xFF007AFF)) Icon(Icons.Default.SettingsVoice, null, Modifier.size(20.dp), tint = if (selectedTab == 1) Color.White else Color(0xFF007AFF))
Text("음성", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = if (selectedTab == 1) Color.White else Color(0xFF007AFF)) Text(stringResource(R.string.voice_input), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = if (selectedTab == 1) Color.White else Color(0xFF007AFF))
} }
} }
// 카메라 // 카메라
@@ -161,7 +163,7 @@ fun CatheterizeSheet(
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.CameraAlt, null, Modifier.size(20.dp), tint = Color(0xFF4CAF50)) Icon(Icons.Default.CameraAlt, null, Modifier.size(20.dp), tint = Color(0xFF4CAF50))
Text("카메라", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFF4CAF50)) Text(stringResource(R.string.camera_input), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFF4CAF50))
} }
} }
} }
@@ -175,7 +177,7 @@ fun CatheterizeSheet(
OutlinedTextField( OutlinedTextField(
value = volumeText, value = volumeText,
onValueChange = { volumeText = it.filter { c -> c.isDigit() }.take(4) }, onValueChange = { volumeText = it.filter { c -> c.isDigit() }.take(4) },
placeholder = { Text("예: 200", color = Color.Gray) }, placeholder = { Text(stringResource(R.string.volume_placeholder), color = Color.Gray) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true, singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 32.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), textStyle = androidx.compose.ui.text.TextStyle(fontSize = 32.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
@@ -193,7 +195,7 @@ fun CatheterizeSheet(
modifier = Modifier.fillMaxWidth().height(48.dp), modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFF9800)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFF9800))
) { Text("저장하기", fontWeight = FontWeight.SemiBold) } ) { Text(stringResource(R.string.save_to_diary), fontWeight = FontWeight.SemiBold) }
} }
1 -> { 1 -> {
// 음성 입력 // 음성 입력
@@ -209,7 +211,7 @@ fun CatheterizeSheet(
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
if (isListening) "듣는 중..." else "탭하여 음성 입력", if (isListening) stringResource(R.string.listening) else stringResource(R.string.tap_to_speak),
color = Color.Gray, fontSize = 14.sp color = Color.Gray, fontSize = 14.sp
) )
if (voiceStatus.isNotEmpty()) { if (voiceStatus.isNotEmpty()) {
@@ -35,7 +35,9 @@ import com.example.medilightv2android.ui.components.BladdyRiveView
import com.example.medilightv2android.ui.components.BleDisconnectionBanner import com.example.medilightv2android.ui.components.BleDisconnectionBanner
import com.example.medilightv2android.ui.components.BleReconnectionState import com.example.medilightv2android.ui.components.BleReconnectionState
import com.example.medilightv2android.ui.components.DonutChartView import com.example.medilightv2android.ui.components.DonutChartView
import com.example.medilightv2android.R
import com.example.medilightv2android.ui.theme.* import com.example.medilightv2android.ui.theme.*
import androidx.compose.ui.res.stringResource
@Composable @Composable
fun PiezoMonitoringView(appState: AppState) { fun PiezoMonitoringView(appState: AppState) {
@@ -139,23 +141,23 @@ fun PiezoMonitoringView(appState: AppState) {
if (showCatheterizeSheet) { if (showCatheterizeSheet) {
AlertDialog( AlertDialog(
onDismissRequest = { showCatheterizeSheet = false }, onDismissRequest = { showCatheterizeSheet = false },
title = { Text("배뇨 기록", fontWeight = FontWeight.Bold) }, title = { Text(stringResource(R.string.voiding_record), fontWeight = FontWeight.Bold) },
text = { Text("입력 방식을 선택하세요") }, text = { Text(stringResource(R.string.select_input_method)) },
confirmButton = {}, confirmButton = {},
dismissButton = { dismissButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { TextButton(onClick = {
showCatheterizeSheet = false showCatheterizeSheet = false
showManualInput = true showManualInput = true
}) { Text("수기") } }) { Text(stringResource(R.string.manual_input)) }
TextButton(onClick = { TextButton(onClick = {
showCatheterizeSheet = false showCatheterizeSheet = false
showVoiceInput = true showVoiceInput = true
}) { Text("음성") } }) { Text(stringResource(R.string.voice_input)) }
TextButton(onClick = { TextButton(onClick = {
showCatheterizeSheet = false showCatheterizeSheet = false
showUrineCamera = true showUrineCamera = true
}) { Text("카메라") } }) { Text(stringResource(R.string.camera_input)) }
} }
} }
) )
@@ -166,13 +168,13 @@ fun PiezoMonitoringView(appState: AppState) {
var volumeText by remember { mutableStateOf(appState.estimatedVolume.toString()) } var volumeText by remember { mutableStateOf(appState.estimatedVolume.toString()) }
AlertDialog( AlertDialog(
onDismissRequest = { showManualInput = false }, onDismissRequest = { showManualInput = false },
title = { Text("소변량 입력") }, title = { Text(stringResource(R.string.volume_input_title)) },
text = { text = {
androidx.compose.foundation.layout.Row(verticalAlignment = Alignment.CenterVertically) { androidx.compose.foundation.layout.Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField( OutlinedTextField(
value = volumeText, value = volumeText,
onValueChange = { volumeText = it.filter { c -> c.isDigit() }.take(4) }, onValueChange = { volumeText = it.filter { c -> c.isDigit() }.take(4) },
placeholder = { Text("예: ${appState.estimatedVolume}") }, placeholder = { Text(stringResource(R.string.volume_placeholder)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number),
singleLine = true, singleLine = true,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -189,12 +191,12 @@ fun PiezoMonitoringView(appState: AppState) {
context, com.example.medilightv2android.measure.VoidingRecord(volume = vol, method = "manual") context, com.example.medilightv2android.measure.VoidingRecord(volume = vol, method = "manual")
) )
appState.resetLevel() appState.resetLevel()
android.widget.Toast.makeText(context, "배뇨일지에 등록되었습니다", android.widget.Toast.LENGTH_SHORT).show() android.widget.Toast.makeText(context, context.getString(R.string.registered_diary), android.widget.Toast.LENGTH_SHORT).show()
showManualInput = false showManualInput = false
} }
}) { Text("저장") } }) { Text(stringResource(R.string.save)) }
}, },
dismissButton = { TextButton(onClick = { showManualInput = false }) { Text("취소") } } dismissButton = { TextButton(onClick = { showManualInput = false }) { Text(stringResource(R.string.cancel)) } }
) )
} }
@@ -221,14 +223,14 @@ fun PiezoMonitoringView(appState: AppState) {
context, com.example.medilightv2android.measure.VoidingRecord(volume = parsed.volumeMl, method = "voice") context, com.example.medilightv2android.measure.VoidingRecord(volume = parsed.volumeMl, method = "voice")
) )
appState.resetLevel() appState.resetLevel()
android.widget.Toast.makeText(context, "${parsed.volumeMl} ml 등록되었습니다", android.widget.Toast.LENGTH_SHORT).show() android.widget.Toast.makeText(context, context.getString(R.string.registered_ml, parsed.volumeMl), android.widget.Toast.LENGTH_SHORT).show()
showVoiceInput = false showVoiceInput = false
} else { } else {
voiceResult = "인식 실패: ${candidates.firstOrNull() ?: ""}" voiceResult = context.getString(R.string.recognition_fail, candidates.firstOrNull() ?: "")
} }
}, },
onError = { code -> isListening = false; voiceResult = "오류 ($code)" }, onError = { code -> isListening = false; voiceResult = context.getString(R.string.error_code, code) },
onReady = { voiceResult = "말씀하세요..." } onReady = { voiceResult = context.getString(R.string.speak_now) }
) )
speechMgr?.startListening() speechMgr?.startListening()
} }
@@ -236,17 +238,17 @@ fun PiezoMonitoringView(appState: AppState) {
AlertDialog( AlertDialog(
onDismissRequest = { speechMgr?.destroy(); showVoiceInput = false }, onDismissRequest = { speechMgr?.destroy(); showVoiceInput = false },
title = { Text(if (isListening) "듣는 중..." else "음성 입력") }, title = { Text(stringResource(if (isListening) R.string.listening else R.string.voice_input)) },
text = { Text(voiceResult ?: "마이크 권한을 확인하세요") }, text = { Text(voiceResult ?: stringResource(R.string.check_mic_permission)) },
confirmButton = { confirmButton = {
if (!isListening && voiceResult != null) { if (!isListening && voiceResult != null) {
TextButton(onClick = { TextButton(onClick = {
isListening = true; voiceResult = null isListening = true; voiceResult = null
speechMgr?.startListening() speechMgr?.startListening()
}) { Text("다시 시도") } }) { Text(stringResource(R.string.retry)) }
} }
}, },
dismissButton = { TextButton(onClick = { speechMgr?.destroy(); showVoiceInput = false }) { Text("취소") } } dismissButton = { TextButton(onClick = { speechMgr?.destroy(); showVoiceInput = false }) { Text(stringResource(R.string.cancel)) } }
) )
} }
@@ -275,10 +277,10 @@ fun PiezoMonitoringView(appState: AppState) {
Icon(Icons.Default.Home, "Home", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp)) Icon(Icons.Default.Home, "Home", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
} }
IconButton(onClick = { appState.currentScreen = AppScreen.VOIDING_DIARY }, modifier = Modifier.size(48.dp)) { IconButton(onClick = { appState.currentScreen = AppScreen.VOIDING_DIARY }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.Description, contentDescription = "배뇨일지", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp)) Icon(Icons.Default.Description, stringResource(R.string.voiding_diary), tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
} }
IconButton(onClick = { appState.currentScreen = AppScreen.REMINDER_SETTINGS }, modifier = Modifier.size(48.dp)) { IconButton(onClick = { appState.currentScreen = AppScreen.REMINDER_SETTINGS }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.Notifications, contentDescription = "배뇨 알리미", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp)) Icon(Icons.Default.Notifications, contentDescription = "Reminder", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
} }
if (!isCompact) { if (!isCompact) {
Text("Bladder Monitor", fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp)) Text("Bladder Monitor", fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp))
@@ -32,8 +32,10 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
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.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.example.medilightv2android.R
import com.example.medilightv2android.measure.SimpleMeasureService import com.example.medilightv2android.measure.SimpleMeasureService
import com.example.medilightv2android.measure.VoidingRecord import com.example.medilightv2android.measure.VoidingRecord
import com.example.medilightv2android.measure.VoidingRecordStore import com.example.medilightv2android.measure.VoidingRecordStore
@@ -149,9 +151,9 @@ fun UrineCameraScreen(
if (!hasCameraPerm) { if (!hasCameraPerm) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("카메라 권한이 필요합니다") Text(stringResource(R.string.camera_permission_needed))
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Button(onClick = { permLauncher.launch(Manifest.permission.CAMERA) }) { Text("권한 허용") } Button(onClick = { permLauncher.launch(Manifest.permission.CAMERA) }) { Text(stringResource(R.string.grant_permission)) }
} }
} }
} else if (capturedBitmap != null) { } else if (capturedBitmap != null) {
@@ -161,8 +163,8 @@ fun UrineCameraScreen(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) {
IconButton(onClick = onCancel) { Icon(Icons.Default.ArrowBack, "뒤로") } IconButton(onClick = onCancel) { Icon(Icons.Default.ArrowBack, stringResource(R.string.back)) }
Text("소변량 측정", fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.urine_measurement), fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(48.dp)) Spacer(Modifier.size(48.dp))
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -173,37 +175,37 @@ fun UrineCameraScreen(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.size(24.dp)) CircularProgressIndicator(Modifier.size(24.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("분석 중...", color = Color.Gray) Text(stringResource(R.string.analyzing), color = Color.Gray)
} }
} else if (analysisComplete) { } else if (analysisComplete) {
if (resultVolume != null && resultVolume!! > 0) { if (resultVolume != null && resultVolume!! > 0) {
Text("측정 결과", color = Color.Gray, fontSize = 14.sp) Text(stringResource(R.string.measurement_result), color = Color.Gray, fontSize = 14.sp)
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {
Text("$resultVolume", fontSize = 48.sp, fontWeight = FontWeight.Bold, color = Color(0xFF007AFF)) Text("$resultVolume", fontSize = 48.sp, fontWeight = FontWeight.Bold, color = Color(0xFF007AFF))
Text(" ml", fontSize = 20.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 8.dp)) Text(" ml", fontSize = 20.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 8.dp))
} }
} else { } else {
Text("측정 실패", fontSize = 20.sp, color = Color.Red) Text(stringResource(R.string.measurement_fail), fontSize = 20.sp, color = Color.Red)
Text("밝은 곳에서 다시 촬영해주세요", color = Color.Gray, fontSize = 14.sp) Text(stringResource(R.string.retry_in_bright), color = Color.Gray, fontSize = 14.sp)
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton( OutlinedButton(
onClick = { capturedBitmap = null; originalBitmap = null; analysisComplete = false; resultVolume = null; isAutoCapturing = false; detectionStartTime = null; autoCaptureProgress = 0f }, onClick = { capturedBitmap = null; originalBitmap = null; analysisComplete = false; resultVolume = null; isAutoCapturing = false; detectionStartTime = null; autoCaptureProgress = 0f },
Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp) Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp)
) { Text("다시 촬영") } ) { Text(stringResource(R.string.retake_photo)) }
Button( Button(
onClick = { onClick = {
val vol = resultVolume val vol = resultVolume
if (vol != null && vol > 0) { if (vol != null && vol > 0) {
VoidingRecordStore.add(context, VoidingRecord(volume = vol, method = "camera")) VoidingRecordStore.add(context, VoidingRecord(volume = vol, method = "camera"))
Toast.makeText(context, "배뇨일지에 등록되었습니다", Toast.LENGTH_SHORT).show() Toast.makeText(context, context.getString(R.string.registered_diary), Toast.LENGTH_SHORT).show()
} }
onDone(vol) onDone(vol)
}, },
Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp), Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50))
) { Text("저장하기", fontWeight = FontWeight.SemiBold) } ) { Text(stringResource(R.string.save_to_diary), fontWeight = FontWeight.SemiBold) }
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -242,14 +244,14 @@ fun UrineCameraScreen(
// 상단 // 상단
Row(Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onCancel) { Icon(Icons.Default.Close, "닫기", tint = Color.White) } IconButton(onClick = onCancel) { Icon(Icons.Default.Close, stringResource(R.string.close), tint = Color.White) }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
val statusText = when { val statusText = when {
!isModelLoaded -> "모델 로딩 중..." !isModelLoaded -> stringResource(R.string.model_loading)
isAutoCapturing -> "자동 촬영 중..." isAutoCapturing -> stringResource(R.string.auto_capturing)
autoCaptureProgress > 0 -> "유지하세요... ${(autoCaptureProgress * 100).toInt()}%" autoCaptureProgress > 0 -> stringResource(R.string.hold_still, (autoCaptureProgress * 100).toInt())
currentDetection != null -> "소변컵 감지됨" currentDetection != null -> stringResource(R.string.cup_detected)
else -> "소변컵을 찾는 중..." else -> stringResource(R.string.searching_cup)
} }
Text(statusText, color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, Text(statusText, color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
modifier = Modifier.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(20.dp)).padding(horizontal = 12.dp, vertical = 6.dp)) modifier = Modifier.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(20.dp)).padding(horizontal = 12.dp, vertical = 6.dp))
@@ -258,8 +260,8 @@ fun UrineCameraScreen(
// 가이드 // 가이드
Column(Modifier.align(Alignment.Center).offset(y = 120.dp).background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(10.dp)).padding(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.align(Alignment.Center).offset(y = 120.dp).background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(10.dp)).padding(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("눈금이 보이는 면을 정면으로", color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.camera_guide_1), color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Text("밝은 곳에서 촬영해주세요", color = Color(0xFFFFD54F), fontSize = 12.sp) Text(stringResource(R.string.camera_guide_2), color = Color(0xFFFFD54F), fontSize = 12.sp)
if (autoCaptureProgress > 0) { if (autoCaptureProgress > 0) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
LinearProgressIndicator(progress = { autoCaptureProgress }, Modifier.fillMaxWidth(0.6f).height(4.dp).clip(RoundedCornerShape(2.dp)), color = Color.Green, trackColor = Color.White.copy(alpha = 0.3f)) LinearProgressIndicator(progress = { autoCaptureProgress }, Modifier.fillMaxWidth(0.6f).height(4.dp).clip(RoundedCornerShape(2.dp)), color = Color.Green, trackColor = Color.White.copy(alpha = 0.3f))
@@ -15,10 +15,12 @@ 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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
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.measure.VoidingRecord import com.example.medilightv2android.measure.VoidingRecord
import com.example.medilightv2android.measure.VoidingRecordStore import com.example.medilightv2android.measure.VoidingRecordStore
import com.example.medilightv2android.ui.theme.MlCardBackground import com.example.medilightv2android.ui.theme.MlCardBackground
@@ -38,8 +40,6 @@ fun VoidingDiaryView(onBack: () -> Unit) {
var showDeleteConfirm by remember { mutableStateOf<String?>(null) } var showDeleteConfirm by remember { mutableStateOf<String?>(null) }
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) } val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) }
val displayDateFormat = remember { SimpleDateFormat("M월 d일 (E)", Locale.KOREA) }
val grouped = remember(records) { val grouped = remember(records) {
records.groupBy { dateFormat.format(Date(it.timestamp)) } records.groupBy { dateFormat.format(Date(it.timestamp)) }
.toSortedMap(compareByDescending { it }) .toSortedMap(compareByDescending { it })
@@ -51,9 +51,9 @@ fun VoidingDiaryView(onBack: () -> Unit) {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("배뇨일지", fontWeight = FontWeight.SemiBold) }, title = { Text(stringResource(R.string.voiding_diary), fontWeight = FontWeight.SemiBold) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로") } IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }
} }
) )
} }
@@ -63,7 +63,7 @@ fun VoidingDiaryView(onBack: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Description, contentDescription = null, Modifier.size(48.dp), Color.Gray.copy(alpha = 0.5f)) Icon(Icons.Default.Description, contentDescription = null, Modifier.size(48.dp), Color.Gray.copy(alpha = 0.5f))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("기록이 없습니다", color = Color.Gray) Text(stringResource(R.string.no_records), color = Color.Gray)
} }
} }
} else { } else {
@@ -79,7 +79,7 @@ fun VoidingDiaryView(onBack: () -> Unit) {
item { item {
val displayDate = try { val displayDate = try {
val d = dateFormat.parse(date) val d = dateFormat.parse(date)
displayDateFormat.format(d!!) SimpleDateFormat("M/d (E)", Locale.getDefault()).format(d!!)
} catch (_: Exception) { date } } catch (_: Exception) { date }
Text(displayDate, fontWeight = FontWeight.Bold, fontSize = 15.sp, Text(displayDate, fontWeight = FontWeight.Bold, fontSize = 15.sp,
@@ -106,21 +106,21 @@ fun VoidingDiaryView(onBack: () -> Unit) {
record.activity == "awake" -> { record.activity == "awake" -> {
Icon(Icons.Default.WbSunny, contentDescription = null, Modifier.size(16.dp), Color(0xFFFF9800)) Icon(Icons.Default.WbSunny, contentDescription = null, Modifier.size(16.dp), Color(0xFFFF9800))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("기상") Text(stringResource(R.string.awake))
} }
record.activity == "sleep" -> { record.activity == "sleep" -> {
Icon(Icons.Default.DarkMode, contentDescription = null, Modifier.size(16.dp), Color(0xFF9C27B0)) Icon(Icons.Default.DarkMode, contentDescription = null, Modifier.size(16.dp), Color(0xFF9C27B0))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("취침") Text(stringResource(R.string.sleep))
} }
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
val methodLabel = when (record.method) { val methodLabel = when (record.method) {
"voice" -> "음성" "voice" -> stringResource(R.string.method_voice)
"camera" -> "카메라" "camera" -> stringResource(R.string.method_camera)
else -> "수기" else -> stringResource(R.string.method_manual)
} }
Text(methodLabel, fontSize = 10.sp, color = Color.Gray, Text(methodLabel, fontSize = 10.sp, color = Color.Gray,
modifier = Modifier.background(Color.Gray.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) modifier = Modifier.background(Color.Gray.copy(alpha = 0.15f), RoundedCornerShape(4.dp))
@@ -132,7 +132,7 @@ fun VoidingDiaryView(onBack: () -> Unit) {
onClick = { showDeleteConfirm = record.id }, onClick = { showDeleteConfirm = record.id },
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp)
) { ) {
Icon(Icons.Default.Delete, contentDescription = "삭제", Modifier.size(20.dp), Color.Gray) Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete), Modifier.size(20.dp), Color.Gray)
} }
} }
} }
@@ -140,7 +140,7 @@ fun VoidingDiaryView(onBack: () -> Unit) {
item { item {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("총 ${records.size}건", color = Color.Gray, fontSize = 12.sp, Text(stringResource(R.string.total_records, records.size), color = Color.Gray, fontSize = 12.sp,
modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.Center) modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
} }
} }
@@ -151,16 +151,16 @@ fun VoidingDiaryView(onBack: () -> Unit) {
showDeleteConfirm?.let { id -> showDeleteConfirm?.let { id ->
AlertDialog( AlertDialog(
onDismissRequest = { showDeleteConfirm = null }, onDismissRequest = { showDeleteConfirm = null },
title = { Text("기록 삭제") }, title = { Text(stringResource(R.string.delete_record)) },
text = { Text("이 기록을 삭제하시겠습니까?") }, text = { Text(stringResource(R.string.delete_confirm_msg)) },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
VoidingRecordStore.delete(context, id) VoidingRecordStore.delete(context, id)
records = VoidingRecordStore.getAll(context).sortedByDescending { it.timestamp } records = VoidingRecordStore.getAll(context).sortedByDescending { it.timestamp }
showDeleteConfirm = null showDeleteConfirm = null
}) { Text("삭제", color = Color.Red) } }) { Text(stringResource(R.string.delete), color = Color.Red) }
}, },
dismissButton = { TextButton(onClick = { showDeleteConfirm = null }) { Text("취소") } } dismissButton = { TextButton(onClick = { showDeleteConfirm = null }) { Text(stringResource(R.string.cancel)) } }
) )
} }
} }
+71
View File
@@ -0,0 +1,71 @@
<resources>
<string name="app_name">MedilightV2</string>
<!-- Common -->
<string name="confirm">확인</string>
<string name="cancel">취소</string>
<string name="close">닫기</string>
<string name="save">저장</string>
<string name="delete">삭제</string>
<string name="retry">다시 시도</string>
<string name="back">뒤로</string>
<!-- Voiding Record -->
<string name="voiding_record">배뇨 기록</string>
<string name="select_input_method">입력 방식을 선택하세요</string>
<string name="manual_input">수기</string>
<string name="voice_input">음성</string>
<string name="camera_input">카메라</string>
<string name="volume_input_title">소변량 입력</string>
<string name="volume_placeholder">예: 200</string>
<string name="registered_diary">배뇨일지에 등록되었습니다</string>
<string name="registered_ml">%d ml 등록되었습니다</string>
<string name="recognition_fail">인식 실패: %s</string>
<string name="error_code">오류 (%d)</string>
<string name="speak_now">말씀하세요…</string>
<string name="listening">듣는 중…</string>
<string name="check_mic_permission">마이크 권한을 확인하세요</string>
<string name="voiding_diary">배뇨일지</string>
<!-- Voiding Diary List -->
<string name="no_records">기록이 없습니다</string>
<string name="delete_record">기록 삭제</string>
<string name="delete_confirm_msg">이 기록을 삭제하시겠습니까?</string>
<string name="awake">기상</string>
<string name="sleep">취침</string>
<string name="method_voice">음성</string>
<string name="method_camera">카메라</string>
<string name="method_manual">수기</string>
<string name="record">기록</string>
<string name="total_records">총 %d건</string>
<string name="urine_volume_ml">소변량 %d ml</string>
<!-- Urine Camera -->
<string name="camera_permission_needed">카메라 권한이 필요합니다</string>
<string name="grant_permission">권한 허용</string>
<string name="urine_measurement">소변량 측정</string>
<string name="analyzing">분석 중…</string>
<string name="measurement_result">측정 결과</string>
<string name="measurement_fail">측정 실패</string>
<string name="retry_in_bright">밝은 곳에서 다시 촬영해주세요</string>
<string name="retake_photo">다시 촬영</string>
<string name="save_to_diary">저장하기</string>
<string name="camera_guide_1">눈금이 보이는 면을 정면으로</string>
<string name="camera_guide_2">밝은 곳에서 촬영해주세요</string>
<!-- Catheterize Sheet -->
<string name="save_complete">저장 완료</string>
<string name="tap_to_speak">탭하여 음성 입력</string>
<!-- Camera Status -->
<string name="model_loading">모델 로딩 중…</string>
<string name="auto_capturing">자동 촬영 중…</string>
<string name="hold_still">유지하세요… %d%%</string>
<string name="cup_detected">소변컵 감지됨</string>
<string name="searching_cup">소변컵을 찾는 중…</string>
<!-- Measurement -->
<string name="too_dark">이미지가 너무 어둡습니다</string>
<string name="not_enough_marks">눈금 부족</string>
<string name="detection_fail">감지 실패</string>
</resources>
+69 -1
View File
@@ -1,3 +1,71 @@
<resources> <resources>
<string name="app_name">medilightv2android</string> <string name="app_name">MedilightV2</string>
<!-- Common -->
<string name="confirm">OK</string>
<string name="cancel">Cancel</string>
<string name="close">Close</string>
<string name="save">Save</string>
<string name="delete">Delete</string>
<string name="retry">Retry</string>
<string name="back">Back</string>
<!-- Voiding Record -->
<string name="voiding_record">Voiding Record</string>
<string name="select_input_method">Select input method</string>
<string name="manual_input">Manual</string>
<string name="voice_input">Voice</string>
<string name="camera_input">Camera</string>
<string name="volume_input_title">Enter Volume</string>
<string name="volume_placeholder">e.g. 200</string>
<string name="registered_diary">Registered to voiding diary</string>
<string name="registered_ml">%d ml registered</string>
<string name="recognition_fail">Recognition failed: %s</string>
<string name="error_code">Error (%d)</string>
<string name="speak_now">Speak now…</string>
<string name="listening">Listening…</string>
<string name="check_mic_permission">Check microphone permission</string>
<string name="voiding_diary">Voiding Diary</string>
<!-- Voiding Diary List -->
<string name="no_records">No records</string>
<string name="delete_record">Delete Record</string>
<string name="delete_confirm_msg">Delete this record?</string>
<string name="awake">Wake up</string>
<string name="sleep">Bedtime</string>
<string name="method_voice">Voice</string>
<string name="method_camera">Camera</string>
<string name="method_manual">Manual</string>
<string name="record">Record</string>
<string name="total_records">Total %d</string>
<string name="urine_volume_ml">Volume %d ml</string>
<!-- Urine Camera -->
<string name="camera_permission_needed">Camera permission required</string>
<string name="grant_permission">Grant Permission</string>
<string name="urine_measurement">Urine Measurement</string>
<string name="analyzing">Analyzing…</string>
<string name="measurement_result">Measurement Result</string>
<string name="measurement_fail">Measurement Failed</string>
<string name="retry_in_bright">Try again in a brighter area</string>
<string name="retake_photo">Retake Photo</string>
<string name="save_to_diary">Save</string>
<string name="camera_guide_1">Face the graduated side forward</string>
<string name="camera_guide_2">Take photo in a bright area</string>
<!-- Catheterize Sheet -->
<string name="save_complete">Saved</string>
<string name="tap_to_speak">Tap to speak</string>
<!-- Camera Status -->
<string name="model_loading">Loading model…</string>
<string name="auto_capturing">Auto capturing…</string>
<string name="hold_still">Hold still… %d%%</string>
<string name="cup_detected">Cup detected</string>
<string name="searching_cup">Searching for cup…</string>
<!-- Measurement -->
<string name="too_dark">Image is too dark</string>
<string name="not_enough_marks">Not enough marks</string>
<string name="detection_fail">Detection failed</string>
</resources> </resources>