Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -93,6 +97,8 @@ class RunActivity : BindingActivity<ActivityRunBinding>(R.layout.activity_run),
binding.lifecycleOwner = this

initView()
initDistanceComposeView()
initPaceComposeView()
initTimerService()
getCurrentLocation()
showRecord()
Expand Down Expand Up @@ -123,6 +129,30 @@ class RunActivity : BindingActivity<ActivityRunBinding>(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)
Expand All @@ -139,6 +169,7 @@ class RunActivity : BindingActivity<ActivityRunBinding>(R.layout.activity_run),
val isPaused = viewModel.isPaused.value ?: false
if (isPaused) {
timerService?.resumeTimer()
viewModel.onManualResume()
} else {
timerService?.pauseTimer()
}
Expand Down Expand Up @@ -180,6 +211,12 @@ class RunActivity : BindingActivity<ActivityRunBinding>(R.layout.activity_run),
timerData.second
)
updateTimerUI(timerUI)

if (viewModel.shouldAutoPause()) {
timerService?.pauseTimer()
viewModel.isPaused.value = true
updatePauseResumeUI(true)
}
}
}

Expand Down Expand Up @@ -239,6 +276,7 @@ class RunActivity : BindingActivity<ActivityRunBinding>(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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -10,4 +12,93 @@ class RunViewModel : ViewModel() {
var courseId = MutableLiveData<Int>()
var publicCourseId = MutableLiveData<Int?>()
val isPaused = MutableLiveData(false)
}

// 실시간 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<Double?>(null)
private val recentSamples = ArrayDeque<Pair<Long, LatLng>>()

// 무활동 자동 일시정지 트리거 여부를 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
}
}
40 changes: 14 additions & 26 deletions app/src/main/res/layout/activity_run.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<View
android:id="@+id/view_run_top_frame"
android:layout_width="match_parent"
android:layout_height="126dp"
android:layout_height="152dp"
android:background="@drawable/bottom_right_radius_40_white"
android:elevation="10dp"
app:layout_constraintEnd_toEndOf="parent"
Expand Down Expand Up @@ -65,35 +65,14 @@
app:layout_constraintStart_toEndOf="@id/iv_total_distance"
app:layout_constraintTop_toTopOf="@id/iv_total_distance" />

<TextView
android:id="@+id/tv_total_distance_content"
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_run_distance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:elevation="11dp"
android:fontFamily="@font/pretendard_bold"
android:outlineProvider="none"
android:text="@{model.distanceSum.toString()}"
android:textColor="@color/G1"
android:textSize="24sp"
app:layout_constraintEnd_toStartOf="@id/tv_total_distance_unit"
app:layout_constraintStart_toStartOf="@id/iv_total_distance"
app:layout_constraintTop_toBottomOf="@id/iv_total_distance"
tools:text="1.6" />

<TextView
android:id="@+id/tv_total_distance_unit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:elevation="11dp"
android:fontFamily="@font/pretendard_medium"
android:outlineProvider="none"
android:text="@string/run_distance_unit"
android:textColor="@color/G2"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@id/tv_total_distance_content"
app:layout_constraintStart_toEndOf="@id/tv_total_distance_content" />
app:layout_constraintTop_toBottomOf="@id/iv_total_distance" />

<ImageView
android:id="@+id/iv_time"
Expand Down Expand Up @@ -130,10 +109,19 @@
android:outlineProvider="none"
android:textColor="@color/G1"
android:textSize="24sp"
app:layout_constraintBottom_toBottomOf="@id/tv_total_distance_unit"
app:layout_constraintBottom_toBottomOf="@id/compose_run_distance"
app:layout_constraintStart_toStartOf="@id/iv_time"
tools:text="00:00:00" />

<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_run_pace"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:elevation="11dp"
app:layout_constraintStart_toStartOf="@id/iv_total_distance"
app:layout_constraintTop_toBottomOf="@id/compose_run_distance" />

<ImageView
android:id="@+id/iv_top_star"
android:layout_width="wrap_content"
Expand Down
Loading
Loading