Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

모바일 앱(안드로이드)

🦾 Android | 메인 화면 뒤로가기 UX 개선, 워치/폰 걸음수 분리 표시, 설정 화면 카드화

Billcorea 2025. 12. 21. 15:01
반응형

 

🦾 Android | 메인 화면 뒤로가기 UX 개선, 워치/폰 걸음수 분리 표시, 설정 화면 카드화


워치앱 샘플

개요 (Intro)

  • 오늘의 목표 / 배경: 메인 화면의 종료 UX 개선, 워치에서 측정된 데이터의 폰 저장 및 표시 강화, 설정 화면을 카드/아이콘/설명으로 가독성 개선
  • 해결하려는 문제: 뒤로가기 오작동/실수 종료 방지, 걸음수 출처(폰/워치) 혼재 표시 개선, 슬라이더 설명 부족 개선
  • 사용한 기술 스택: Kotlin, Jetpack Compose, Room, Hilt, Wearable APIs
📅 날짜: 2025.12.21
🎯 목표: 뒤로가기 2회 종료, Steps/Altitude 저장 및 폰/워치 분리 표시, 설정 화면 카드화
🧰 기술: Kotlin, Android Studio, Compose, Room, Hilt, Wear OS

문제 정의 (Problem / Motivation)

  • 뒤로가기 버튼 한 번만 눌러도 앱이 종료되어 실수로 종료되는 문제가 있었음.
  • 워치/폰에서 들어오는 걸음수가 동일 시리즈로 묶여 출처 구분이 어려웠음.
  • 설정 화면의 슬라이더/옵션에 설명이 부족해 일반 사용자에게 난이도가 높았음.
// 예시: 기존 액티비티에서 onBackPressed()로 직접 처리하던 로직
override fun onBackPressed() {
    // 한 번만 눌러도 finish()로 종료 -> 실수 종료 가능성
    finish()
}

해결 과정 (How I Solved It)

  • 뒤로가기: Activity의 onBackPressed 커스텀 로직을 제거하고, Compose의 BackHandler로 "두 번 눌러야 종료"를 구현. 첫 눌림엔 안내 다이얼로그 표시.
  • 데이터 저장: WearDataSaver와 ViewModel에서 Altitude/Steps/Location을 Room Repository에 안전 저장.
  • 표시: MainScreen에서 Steps를 source("phone","wear") 기준으로 분리 집계/시각화. 중앙 StepIndicator를 폰/워치로 2개 배치.
  • 설정 화면: 카드 스타일 + 아이콘 + 간략 설명/권장 범위 문자열을 strings.xml로 옮겨 i18n 대응.
// Compose에서 뒤로가기 두 번 눌러야 종료 (핵심 부분)
@Composable
fun HealthConnectApp(...) {
    var backPressedOnce by remember { mutableStateOf(false) }
    var showConfirmDialog by remember { mutableStateOf(false) }

    // 첫 눌림 후 3초 내 재설정
    LaunchedEffect(backPressedOnce) {
        if (backPressedOnce) {
            showConfirmDialog = true
            delay(3000)
            backPressedOnce = false
            showConfirmDialog = false
        }
    }

    BackHandler(enabled = true) {
        if (backPressedOnce) {
            // 두 번째 눌림: 전체 종료 (finishAffinity)
            (LocalContext.current as? Activity)?.finishAffinity()
        } else {
            // 첫 눌림: 안내 다이얼로그 표시
            backPressedOnce = true
            showConfirmDialog = true
        }
    }

    if (showConfirmDialog) {
        AlertDialog(
            onDismissRequest = { showConfirmDialog = false },
            title = { Text(text = stringResource(R.string.confirm_exit_title)) },
            text = { Text(text = stringResource(R.string.confirm_exit_message)) },
            confirmButton = {
                Button(onClick = { (LocalContext.current as? Activity)?.finishAffinity() }) {
                    Text(text = stringResource(R.string.exit_now))
                }
            },
            dismissButton = {
                Button(onClick = { showConfirmDialog = false }) {
                    Text(text = stringResource(R.string.cancel))
                }
            }
        )
    }
}
// 초보자용 주석: Steps를 폰/워치로 분리 집계하여 차트에 표시하는 로직
val recentSteps = viewModel.recentSavedSteps.collectAsState().value
val todayKey = DateTimeFormatter.ofPattern("yyyyMMdd").format(LocalDate.now())
val stepsHourlyPhone = IntArray(24) { 0 }
val stepsHourlyWear = IntArray(24) { 0 }
var stepCountPhone = 0
var stepCountWear = 0

recentSteps.filter { it.dayKey == todayKey }.forEach { step ->
    // timestamp로 몇 시 데이터인지 계산
    val hour = Instant.ofEpochMilli(step.timestamp).atZone(ZoneId.systemDefault()).hour
    // 음수 델타 보호: 최솟값 0
    val d = step.delta.toInt().coerceAtLeast(0)
    if (step.source.equals("phone", ignoreCase = true)) {
        stepsHourlyPhone[hour] += d
        stepCountPhone += d
    } else {
        stepsHourlyWear[hour] += d
        stepCountWear += d
    }
}
// 차트 시리즈로 변환(스케일링: /10)
val seriesStepsPhone = (0..23).map { h -> stepsHourlyPhone[h] / 10f }
val seriesStepsWear = (0..23).map { h -> stepsHourlyWear[h] / 10f }
// 설정 화면 카드/아이콘/권장 범위 적용 (일부 발췌)
Card(modifier = Modifier.fillMaxWidth()) {
    Column(Modifier.padding(12.dp)) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Icon(Icons.Filled.Height, contentDescription = stringResource(R.string.ic_desc_altitude_fusion))
            Spacer(Modifier.width(8.dp))
            Text(text = stringResource(R.string.card_altitude_fusion_title), style = MaterialTheme.typography.titleMedium)
        }
        Text(text = stringResource(R.string.card_altitude_fusion_desc), color = Color.Gray)
        Spacer(Modifier.height(8.dp))
        Slider(value = altitudeFusionRatio, onValueChange = { viewModel.updateAltitudeFusionRatio(it) }, valueRange = 0f..1f)
        Text(text = stringResource(R.string.rec_alt_fusion), color = Color.Gray)
    }
}

결과 (Result)

  • 뒤로가기 UX: 두 번 눌러야 종료되도록 안전하게 개선. 첫 눌림 시 안내 다이얼로그로 명확한 피드백 제공.
  • 데이터 저장: Altitude/Steps/Location이 각각 Room Repository에 정상 저장.
  • 표시: 메인 차트에 Steps(폰/워치) 분리 시리즈가 추가되고 중앙 StepIndicator도 폰/워치로 분리 표기.
  • 설정 화면: 카드/아이콘/설명/권장 범위로 가독성 향상 및 국제화(strings.xml) 완비.
✅ 뒤로가기 안전 종료 구현 완료
📊 Steps 출처 분리로 분석 용이성 향상
🧭 설정 가독성 개선(카드/아이콘/권장 범위) + i18n 적용

느낀 점 / 회고 (Reflection)

  • 뒤로가기 동작은 Activity와 Compose가 중복되면 혼선이 생기므로 단일 진입점(Compose BackHandler)로 관리하는 게 안정적.
  • 데이터 출처(폰/워치)를 분리해 보여주니 사용자 이해도가 높아지고 디버깅도 쉬워짐.
  • 설정 설명/권장 범위가 있으면 초보 사용자도 안심하고 조절할 수 있음. 차후 실제 측정 환경에 따른 동적 권장값도 고려해볼 만함.

참고자료 (References)


다음 목표: Steps 동적 권장 범위 제안, 다크 모드 대비 향상, 차트 토글(폰/워치 개별 On/Off) 추가

반응형