diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt index 59019f35..ffe0b598 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt @@ -15,6 +15,9 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ViewCompositionStrategy import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices import com.naver.maps.geometry.LatLng @@ -38,6 +41,7 @@ import com.runnect.runnect.databinding.ActivityRunBinding import com.runnect.runnect.presentation.endrun.EndRunActivity import com.runnect.runnect.presentation.run.TimerService.Companion.EXTRA_TIMER_VALUE import com.runnect.runnect.presentation.run.TimerService.Companion.TIMER_UPDATE_ACTION +import com.runnect.runnect.presentation.ui.theme.RunnectTheme import com.runnect.runnect.util.analytics.Analytics import com.runnect.runnect.util.analytics.EventName import com.runnect.runnect.util.analytics.EventName.Param @@ -93,6 +97,8 @@ class RunActivity : BindingActivity(R.layout.activity_run), binding.lifecycleOwner = this initView() + initDistanceComposeView() + initPaceComposeView() initTimerService() getCurrentLocation() showRecord() @@ -123,6 +129,30 @@ class RunActivity : BindingActivity(R.layout.activity_run), ) } + private fun initDistanceComposeView() { + binding.composeRunDistance.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + RunnectTheme { + val distanceKm by viewModel.traveledDistanceKm.observeAsState(0.0) + RunDistanceStat(distanceKm = distanceKm) + } + } + } + } + + private fun initPaceComposeView() { + binding.composeRunPace.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + RunnectTheme { + val paceSecPerKm by viewModel.currentPaceSecPerKm.observeAsState(null) + RunPaceStat(paceSecPerKm = paceSecPerKm) + } + } + } + } + private fun initTimerService() { serviceIntent = Intent(this, TimerService::class.java) startService(serviceIntent) @@ -139,6 +169,7 @@ class RunActivity : BindingActivity(R.layout.activity_run), val isPaused = viewModel.isPaused.value ?: false if (isPaused) { timerService?.resumeTimer() + viewModel.onManualResume() } else { timerService?.pauseTimer() } @@ -180,6 +211,12 @@ class RunActivity : BindingActivity(R.layout.activity_run), timerData.second ) updateTimerUI(timerUI) + + if (viewModel.shouldAutoPause()) { + timerService?.pauseTimer() + viewModel.isPaused.value = true + updatePauseResumeUI(true) + } } } @@ -239,6 +276,7 @@ class RunActivity : BindingActivity(R.layout.activity_run), private fun addCurrentLocationChangeListener(map: NaverMap) { naverMap.addOnLocationChangeListener { location -> currentLocation = LatLng(location.latitude, location.longitude) + viewModel.onLocationUpdated(currentLocation) map.locationOverlay.run { //현재 위치 마커 isVisible = true //현재 위치 마커 가시성(default = false) position = LatLng(currentLocation.latitude, currentLocation.longitude) diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunDistanceStat.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunDistanceStat.kt new file mode 100644 index 00000000..8ead7ce6 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunDistanceStat.kt @@ -0,0 +1,47 @@ +package com.runnect.runnect.presentation.run + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import com.runnect.runnect.presentation.ui.theme.G1 +import com.runnect.runnect.presentation.ui.theme.G2 +import com.runnect.runnect.presentation.ui.theme.PretendardFontFamily +import com.runnect.runnect.presentation.ui.theme.RunnectTheme + +/** + * 러닝 화면 상단 정보 바의 실시간 이동 거리 표시. + * 기존 tv_total_distance_content(값) + tv_total_distance_unit(단위) 두 XML TextView를 + * 대체한다 — 아이콘/라벨("거리")은 위치 고정값이라 XML에 그대로 둔다. + */ +@Composable +fun RunDistanceStat(distanceKm: Double, modifier: Modifier = Modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.Bottom) { + Text( + text = distanceKm.toString(), + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + color = G1, + ) + Text( + text = "km", + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = G2, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun RunDistanceStatPreview() { + RunnectTheme { + RunDistanceStat(distanceKm = 2.3) + } +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunPaceStat.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunPaceStat.kt new file mode 100644 index 00000000..a29b9b03 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunPaceStat.kt @@ -0,0 +1,76 @@ +package com.runnect.runnect.presentation.run + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.runnect.runnect.presentation.ui.theme.G1 +import com.runnect.runnect.presentation.ui.theme.G2 +import com.runnect.runnect.presentation.ui.theme.PretendardFontFamily +import com.runnect.runnect.presentation.ui.theme.RunnectTheme +import kotlin.math.roundToInt + +/** + * 러닝 화면 상단 정보 바의 실시간 페이스(최근 15초 구간 기준) 표시. + * 아직 측정할 만큼 움직이지 않았으면(null) "-"로 표시해 레이아웃이 흔들리지 않게 한다. + */ +@Composable +fun RunPaceStat(paceSecPerKm: Double?, modifier: Modifier = Modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.Bottom) { + Text( + text = "페이스", + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = G2, + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = formatPaceSecPerKm(paceSecPerKm), + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = G1, + ) + if (paceSecPerKm != null) { + Text( + modifier = Modifier.padding(start = 2.dp), + text = "/km", + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = G2, + ) + } + } +} + +private fun formatPaceSecPerKm(paceSecPerKm: Double?): String { + if (paceSecPerKm == null || paceSecPerKm.isNaN() || paceSecPerKm.isInfinite()) return "-" + val totalSec = paceSecPerKm.roundToInt() + val min = totalSec / 60 + val sec = totalSec % 60 + return "%d'%02d\"".format(min, sec) +} + +@Preview(showBackground = true) +@Composable +private fun RunPaceStatPreview() { + RunnectTheme { + RunPaceStat(paceSecPerKm = 330.0) + } +} + +@Preview(showBackground = true) +@Composable +private fun RunPaceStatEmptyPreview() { + RunnectTheme { + RunPaceStat(paceSecPerKm = null) + } +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.kt index 4c7bf98d..c5c152f9 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.kt @@ -2,6 +2,8 @@ package com.runnect.runnect.presentation.run import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.naver.maps.geometry.LatLng +import com.runnect.runnect.util.extension.round class RunViewModel : ViewModel() { var distanceSum = MutableLiveData(0.0) @@ -10,4 +12,93 @@ class RunViewModel : ViewModel() { var courseId = MutableLiveData() var publicCourseId = MutableLiveData() val isPaused = MutableLiveData(false) -} \ No newline at end of file + + // 실시간 GPS 기반 이동 거리 (km). distanceSum(코스 목표 거리, 고정값)과는 별개. + val traveledDistanceKm = MutableLiveData(0.0) + private var traveledDistanceM = 0.0 + private var lastLocation: LatLng? = null + + // 최근 구간(PACE_WINDOW_MILLIS) 거리/시간 기반 실시간 페이스. 너무 적게 움직였으면 null(표시 안 함). + val currentPaceSecPerKm = MutableLiveData(null) + private val recentSamples = ArrayDeque>() + + // 무활동 자동 일시정지 트리거 여부를 RunActivity가 매초(타이머 브로드캐스트) 확인한다. + private var lastMovementAtMillis: Long? = null + + /** + * 위치가 갱신될 때마다 호출한다. 일시정지 중에는 거리/페이스를 누적하지 않되, lastLocation은 + * 갱신해서 재개 시 일시정지 이전 위치와의 거리가 한 번에 더해지지 않게 한다. + * GPS 튐으로 보이는 비정상적으로 큰 한 번의 이동(MAX_PLAUSIBLE_JUMP_M 초과)도 무시한다. + */ + fun onLocationUpdated(newLocation: LatLng, now: Long = System.currentTimeMillis()) { + val previous = lastLocation + + if (previous == null) { + lastLocation = newLocation + lastMovementAtMillis = now // 무활동 타이머 시작 기준점 (러닝 시작 시점) + return + } + if (isPaused.value == true) { + lastLocation = newLocation + return + } + + val deltaM = previous.distanceTo(newLocation) + // 튐으로 판정해 거부한 좌표는 lastLocation을 갱신하지 않는다 — 다음 정상 좌표와의 + // 거리가 튄 좌표 기준으로 잘못 계산되는 것을 막기 위해. + if (deltaM > MAX_PLAUSIBLE_JUMP_M) return + lastLocation = newLocation + + if (deltaM >= MOVEMENT_THRESHOLD_M) { + lastMovementAtMillis = now + } + + traveledDistanceM += deltaM + traveledDistanceKm.value = (traveledDistanceM / 1000).round(1) + + recordPaceSample(now, newLocation) + } + + /** 수동으로 재개했을 때 호출 — 무활동 타이머를 재개 시점 기준으로 초기화해서 재개 직후 바로 자동 일시정지되지 않게 한다. */ + fun onManualResume(now: Long = System.currentTimeMillis()) { + lastMovementAtMillis = now + } + + /** 타이머 브로드캐스트(매초)에서 호출. 무활동 시간이 임계값을 넘었으면 true — RunActivity가 실제 일시정지를 수행한다. */ + fun shouldAutoPause(now: Long = System.currentTimeMillis()): Boolean { + if (isPaused.value == true) return false + val lastMovement = lastMovementAtMillis ?: return false + return (now - lastMovement) >= INACTIVITY_TIMEOUT_MILLIS + } + + private fun recordPaceSample(now: Long, location: LatLng) { + recentSamples.addLast(now to location) + while (recentSamples.isNotEmpty() && now - recentSamples.first().first > PACE_WINDOW_MILLIS) { + recentSamples.removeFirst() + } + currentPaceSecPerKm.value = calculatePaceSecPerKm() + } + + private fun calculatePaceSecPerKm(): Double? { + if (recentSamples.size < 2) return null + + var windowDistanceM = 0.0 + for (i in 1 until recentSamples.size) { + windowDistanceM += recentSamples[i - 1].second.distanceTo(recentSamples[i].second) + } + if (windowDistanceM < MIN_PACE_DISTANCE_M) return null + + val windowDurationSec = (recentSamples.last().first - recentSamples.first().first) / 1000.0 + if (windowDurationSec <= 0) return null + + return (windowDurationSec / windowDistanceM) * 1000 + } + + companion object { + private const val MAX_PLAUSIBLE_JUMP_M = 50.0 + private const val MOVEMENT_THRESHOLD_M = 5.0 + private const val PACE_WINDOW_MILLIS = 15_000L + private const val MIN_PACE_DISTANCE_M = 5.0 + private const val INACTIVITY_TIMEOUT_MILLIS = 60_000L + } +} diff --git a/app/src/main/res/layout/activity_run.xml b/app/src/main/res/layout/activity_run.xml index 54f9bcf7..ac3a87b3 100644 --- a/app/src/main/res/layout/activity_run.xml +++ b/app/src/main/res/layout/activity_run.xml @@ -18,7 +18,7 @@ - - - + app:layout_constraintTop_toBottomOf="@id/iv_total_distance" /> + + + viewModel.onLocationUpdated(LatLng(baseLat + 0.0002 * (i + 1), 126.9780)) + } + + val distanceKm = viewModel.traveledDistanceKm.value ?: 0.0 + assertTrue("누적 거리가 0보다 커야 한다: $distanceKm", distanceKm > 0.0) + assertTrue("누적 거리가 비정상적으로 크면 안 된다: $distanceKm", distanceKm < 0.3) + } + + @Test + fun `일시정지 중에는 이동해도 거리가 누적되지 않는다`() { + viewModel.onLocationUpdated(LatLng(37.5665, 126.9780)) + viewModel.isPaused.value = true + + viewModel.onLocationUpdated(LatLng(37.5675, 126.9780)) + + assertEquals(0.0, viewModel.traveledDistanceKm.value) + } + + @Test + fun `일시정지 해제 후에는 정지 중 이동분을 제외하고 재개 이후 이동만 누적된다`() { + viewModel.onLocationUpdated(LatLng(37.5665, 126.9780)) + viewModel.isPaused.value = true + viewModel.onLocationUpdated(LatLng(37.5715, 126.9780)) // 일시정지 중 약 555m 이동 — 누적되면 안 됨 + viewModel.isPaused.value = false + + viewModel.onLocationUpdated(LatLng(37.5716, 126.9780)) // 재개 후 약 11m만 실제 이동 + + val distanceAfterResume = viewModel.traveledDistanceKm.value ?: 0.0 + assertTrue( + "재개 직후 거리는 일시정지 중 이동분(약 555m)을 포함하면 안 된다: $distanceAfterResume", + distanceAfterResume < 0.1 + ) + } + + @Test + fun `GPS 튐으로 보이는 비정상적으로 큰 한 번의 이동은 무시한다`() { + viewModel.onLocationUpdated(LatLng(37.5665, 126.9780)) + viewModel.onLocationUpdated(LatLng(37.5765, 126.9780)) // 약 1.1km, 임계값(50m) 훨씬 초과 + + assertEquals(0.0, viewModel.traveledDistanceKm.value) + } + + @Test + fun `거부된 튐 좌표는 기준점으로 남지 않고 다음 정상 이동은 튐 이전 위치 기준으로 계산된다`() { + val baseLat = 37.5665 + val now = 0L + viewModel.onLocationUpdated(LatLng(baseLat, 126.9780), now) // 기준점 + // 약 122m 튐 - 거부됨. 이 좌표가 lastLocation으로 남으면 이후 정상 이동까지 잘못 거부/누적된다. + viewModel.onLocationUpdated(LatLng(baseLat + 0.0011, 126.9780), now + 1_000) + // 튐 이전 기준점(baseLat)으로부터 약 44m - 정상 이동이면 누적되어야 한다. + viewModel.onLocationUpdated(LatLng(baseLat + 0.0004, 126.9780), now + 2_000) + // 직전 정상 위치로부터 약 44m 추가 이동 + viewModel.onLocationUpdated(LatLng(baseLat + 0.0008, 126.9780), now + 3_000) + + val distanceKm = viewModel.traveledDistanceKm.value ?: 0.0 + assertTrue( + "튐 좌표가 기준점으로 남으면 이후 정상 이동(총 약 89m)이 누락된다: $distanceKm", + distanceKm >= 0.1 + ) + } + + @Test + fun `충분히 움직이지 않으면 페이스는 null이다`() { + val now = 0L + viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now) + + assertEquals(null, viewModel.currentPaceSecPerKm.value) + } + + @Test + fun `최근 구간 이동 거리와 시간으로 페이스가 계산된다`() { + val now = 0L + viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now) + // 페이스 샘플은 두 번째 위치 업데이트부터 기록되므로, 최소 2개 샘플을 쌓기 위해 3번 갱신한다. + viewModel.onLocationUpdated(LatLng(37.5667, 126.9780), now + 5_000) + viewModel.onLocationUpdated(LatLng(37.5669, 126.9780), now + 10_000) + + val pace = viewModel.currentPaceSecPerKm.value + assertTrue("페이스가 계산되어야 한다: $pace", pace != null && pace > 0) + } + + @Test + fun `일시정지 중에는 페이스가 갱신되지 않는다`() { + val now = 0L + viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now) + viewModel.isPaused.value = true + + viewModel.onLocationUpdated(LatLng(37.5675, 126.9780), now + 10_000) + + assertEquals(null, viewModel.currentPaceSecPerKm.value) + } + + @Test + fun `무활동 시간이 임계값을 넘으면 자동 일시정지가 필요하다`() { + val now = 0L + viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now) + + assertTrue(viewModel.shouldAutoPause(now + 60_000)) + } + + @Test + fun `무활동 시간이 임계값 미만이면 자동 일시정지가 필요없다`() { + val now = 0L + viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now) + + assertEquals(false, viewModel.shouldAutoPause(now + 30_000)) + } + + @Test + fun `이미 일시정지 상태면 자동 일시정지를 다시 트리거하지 않는다`() { + val now = 0L + viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now) + viewModel.isPaused.value = true + + assertEquals(false, viewModel.shouldAutoPause(now + 60_000)) + } + + @Test + fun `수동 재개 직후에는 무활동 타이머가 초기화되어 바로 자동 일시정지되지 않는다`() { + val now = 0L + viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now) + + viewModel.onManualResume(now + 60_000) + + assertEquals(false, viewModel.shouldAutoPause(now + 60_000 + 30_000)) + } + + @Test + fun `움직임이 감지되면 무활동 타이머가 갱신된다`() { + val now = 0L + viewModel.onLocationUpdated(LatLng(37.5665, 126.9780), now) + // 임계값(5m) 이상 이동 -> 무활동 타이머 갱신 + viewModel.onLocationUpdated(LatLng(37.56655, 126.9780), now + 30_000) + + assertEquals(false, viewModel.shouldAutoPause(now + 30_000 + 59_000)) + assertTrue(viewModel.shouldAutoPause(now + 30_000 + 60_000)) + } +}