From 2b5880048c80ac6390032b759ef0cbf983fbf980 Mon Sep 17 00:00:00 2001 From: unam98 Date: Tue, 18 Aug 2026 18:38:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=9F=AC=EB=8B=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20GPS=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=9D=B4=EB=8F=99=20=EA=B1=B0=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EC=A0=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존엔 러닝 중 표시되는 거리가 코스의 목표 거리(고정값)였고 실시간 추적이 전혀 없었음. 실시간 페이스/이탈 알림 기능의 전제조건이라 먼저 구현. - RunViewModel.onLocationUpdated: 연속된 GPS 좌표 사이 거리를 누적. 일시정지 중엔 누적하지 않되 위치는 갱신해서 재개 시 정지 중 이동분이 한 번에 더해지지 않게 함. GPS 튐으로 보이는 50m 초과 단발 이동은 무시. - 거리 값 표시는 Compose로 구현(RunDistanceStat) — 기존 XML data-binding TextView 2개를 ComposeView로 교체, 아이콘/라벨은 그대로 XML에 유지. Test Plan: RunViewModelTest 5개 케이스(최초 업데이트/누적/일시정지/ 재개/GPS 튐 무시) 전체 통과, assembleDebug 성공. --- .../runnect/presentation/run/RunActivity.kt | 18 +++++ .../presentation/run/RunDistanceStat.kt | 47 +++++++++++ .../runnect/presentation/run/RunViewModel.kt | 31 +++++++- app/src/main/res/layout/activity_run.xml | 29 +------ .../presentation/run/RunViewModelTest.kt | 77 +++++++++++++++++++ 5 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/runnect/runnect/presentation/run/RunDistanceStat.kt create mode 100644 app/src/test/java/com/runnect/runnect/presentation/run/RunViewModelTest.kt 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..c2f49839 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,7 @@ class RunActivity : BindingActivity(R.layout.activity_run), binding.lifecycleOwner = this initView() + initDistanceComposeView() initTimerService() getCurrentLocation() showRecord() @@ -123,6 +128,18 @@ 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 initTimerService() { serviceIntent = Intent(this, TimerService::class.java) startService(serviceIntent) @@ -239,6 +256,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/RunViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.kt index 4c7bf98d..f7e050f4 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,31 @@ 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 + + /** + * 위치가 갱신될 때마다 호출한다. 일시정지 중에는 누적하지 않되, lastLocation은 + * 갱신해서 재개 시 일시정지 이전 위치와의 거리가 한 번에 더해지지 않게 한다. + * GPS 튐으로 보이는 비정상적으로 큰 한 번의 이동(MAX_PLAUSIBLE_JUMP_M 초과)도 무시한다. + */ + fun onLocationUpdated(newLocation: LatLng) { + val previous = lastLocation + lastLocation = newLocation + + if (isPaused.value == true || previous == null) return + + val deltaM = previous.distanceTo(newLocation) + if (deltaM > MAX_PLAUSIBLE_JUMP_M) return + + traveledDistanceM += deltaM + traveledDistanceKm.value = (traveledDistanceM / 1000).round(1) + } + + companion object { + private const val MAX_PLAUSIBLE_JUMP_M = 50.0 + } +} diff --git a/app/src/main/res/layout/activity_run.xml b/app/src/main/res/layout/activity_run.xml index 54f9bcf7..25deccbe 100644 --- a/app/src/main/res/layout/activity_run.xml +++ b/app/src/main/res/layout/activity_run.xml @@ -65,35 +65,14 @@ app:layout_constraintStart_toEndOf="@id/iv_total_distance" app:layout_constraintTop_toTopOf="@id/iv_total_distance" /> - - - + app:layout_constraintTop_toBottomOf="@id/iv_total_distance" /> diff --git a/app/src/test/java/com/runnect/runnect/presentation/run/RunViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/run/RunViewModelTest.kt new file mode 100644 index 00000000..9a34499b --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/run/RunViewModelTest.kt @@ -0,0 +1,77 @@ +package com.runnect.runnect.presentation.run + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.naver.maps.geometry.LatLng +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class RunViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var viewModel: RunViewModel + + @Before + fun setUp() { + viewModel = RunViewModel() + } + + @Test + fun `첫 위치 업데이트는 이전 위치가 없어서 거리를 누적하지 않는다`() { + viewModel.onLocationUpdated(LatLng(37.5665, 126.9780)) + + assertEquals(0.0, viewModel.traveledDistanceKm.value) + } + + @Test + fun `연속된 여러 위치 이동만큼 거리가 누적된다`() { + val baseLat = 37.5665 + viewModel.onLocationUpdated(LatLng(baseLat, 126.9780)) + // 각 구간 약 22m씩(임계값 50m 이내), 5구간 합산 약 111m + repeat(5) { i -> + 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) + } +} From 60e5509c9f4857d38977f4d8dd0b8b3432b715f5 Mon Sep 17 00:00:00 2001 From: unam98 Date: Tue, 18 Aug 2026 19:11:04 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=EB=9F=AC=EB=8B=9D=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20=EB=AC=B4=ED=99=9C?= =?UTF-8?q?=EB=8F=99=20=EC=9E=90=EB=8F=99=20=EC=9D=BC=EC=8B=9C=EC=A0=95?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 최근 15초 구간의 이동 거리/시간으로 페이스를 계산해 헤더에 노출하고, 60초간 움직임이 없으면 자동으로 일시정지되도록 했다. 수동 재개 시에는 무활동 타이머를 초기화해 재개 직후 바로 다시 자동 일시정지되지 않게 했다. --- .../runnect/presentation/run/RunActivity.kt | 20 +++++ .../runnect/presentation/run/RunPaceStat.kt | 76 ++++++++++++++++++ .../runnect/presentation/run/RunViewModel.kt | 62 ++++++++++++++- app/src/main/res/layout/activity_run.xml | 11 ++- .../presentation/run/RunViewModelTest.kt | 77 +++++++++++++++++++ 5 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/runnect/runnect/presentation/run/RunPaceStat.kt 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 c2f49839..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 @@ -98,6 +98,7 @@ class RunActivity : BindingActivity(R.layout.activity_run), initView() initDistanceComposeView() + initPaceComposeView() initTimerService() getCurrentLocation() showRecord() @@ -140,6 +141,18 @@ class RunActivity : BindingActivity(R.layout.activity_run), } } + 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) @@ -156,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() } @@ -197,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) + } } } 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 f7e050f4..65092877 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 @@ -18,25 +18,81 @@ class RunViewModel : ViewModel() { 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은 + * 위치가 갱신될 때마다 호출한다. 일시정지 중에는 거리/페이스를 누적하지 않되, lastLocation은 * 갱신해서 재개 시 일시정지 이전 위치와의 거리가 한 번에 더해지지 않게 한다. * GPS 튐으로 보이는 비정상적으로 큰 한 번의 이동(MAX_PLAUSIBLE_JUMP_M 초과)도 무시한다. */ - fun onLocationUpdated(newLocation: LatLng) { + fun onLocationUpdated(newLocation: LatLng, now: Long = System.currentTimeMillis()) { val previous = lastLocation lastLocation = newLocation - if (isPaused.value == true || previous == null) return + if (previous == null) { + lastMovementAtMillis = now // 무활동 타이머 시작 기준점 (러닝 시작 시점) + return + } + if (isPaused.value == true) return val deltaM = previous.distanceTo(newLocation) if (deltaM > MAX_PLAUSIBLE_JUMP_M) return + 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 25deccbe..ac3a87b3 100644 --- a/app/src/main/res/layout/activity_run.xml +++ b/app/src/main/res/layout/activity_run.xml @@ -18,7 +18,7 @@ + + 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)) + } } From 243a5e0916e3992f525db1b1d195688178966092 Mon Sep 17 00:00:00 2001 From: unam98 Date: Tue, 18 Aug 2026 19:30:38 +0900 Subject: [PATCH 3/3] =?UTF-8?q?GPS=20=ED=8A=90=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80=EB=90=9C=20=EC=A2=8C=ED=91=9C=EA=B0=80=20las?= =?UTF-8?q?tLocation=20=EA=B8=B0=EC=A4=80=EC=A0=90=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=82=A8=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 거부된 좌표를 기준으로 다음 정상 이동 거리가 잘못 계산되던 문제를 고쳤다. 활성 상태에서는 튐 필터를 통과한 좌표만 lastLocation을 갱신하도록 하고, 회귀 테스트를 추가했다. CodeRabbit 리뷰 코멘트 반영: https://github.com/Runnect/Runnect-Android/pull/411#discussion_r3803121759 --- .../runnect/presentation/run/RunViewModel.kt | 10 ++++++++-- .../presentation/run/RunViewModelTest.kt | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) 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 65092877..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 @@ -32,16 +32,22 @@ class RunViewModel : ViewModel() { */ fun onLocationUpdated(newLocation: LatLng, now: Long = System.currentTimeMillis()) { val previous = lastLocation - lastLocation = newLocation if (previous == null) { + lastLocation = newLocation lastMovementAtMillis = now // 무활동 타이머 시작 기준점 (러닝 시작 시점) return } - if (isPaused.value == true) 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 diff --git a/app/src/test/java/com/runnect/runnect/presentation/run/RunViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/run/RunViewModelTest.kt index bfdf4590..1f3f6672 100644 --- a/app/src/test/java/com/runnect/runnect/presentation/run/RunViewModelTest.kt +++ b/app/src/test/java/com/runnect/runnect/presentation/run/RunViewModelTest.kt @@ -75,6 +75,25 @@ class RunViewModelTest { 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