feat: 러닝 화면에 일시정지/재개 기능 추가 - #406
Conversation
기존엔 러닝 종료 버튼 하나만 있어 중간에 멈출 수 없었음. 기존 "러닝 종료" 풀와이드 버튼을 원형 일시정지(주) 버튼으로 교체하고, 일시정지 시에만 종료(정지)/재개 버튼 두 개가 나타나도록 했다. 지도는 NRC처럼 별도 화면으로 바꾸지 않고 그대로 유지하되, 옅은 틴트와 "일시정지 중" 배지로 상태를 표시해 코스 경로를 계속 보여준다. TimerService에 pauseTimer/resumeTimer를 추가했는데, 기존 time 값을 초기화하지 않는 startTimer/stopTimer 구조를 그대로 재사용해 일시정지 후 재개하면 멈췄던 지점부터 이어서 카운트된다.
📝 WalkthroughWalkthroughThe run screen now supports pause and resume actions. ChangesRun pause flow
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant Runner
participant RunActivity
participant TimerService
participant RunViewModel
Runner->>RunActivity: Tap pause or resume
RunActivity->>TimerService: Pause or resume timer
RunActivity->>RunViewModel: Update isPaused
RunActivity->>Runner: Update controls and paused label
Runner->>RunActivity: Tap stop
RunActivity->>TimerService: Stop timer
RunActivity->>Runner: Navigate to run completion
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt (1)
364-384: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick winInitialize
timerDatabefore stop can use it.
timerDataislateinitand is only assigned bytimerReceiver, but the stop path reads it immediately after callingstopTimer(). If stop runs before the first timer broadcast,RunToEndRunDataconstruction accesses an uninitialized property. InitializetimerDatato zero time before enabling stop, or pass the current elapsed time fromTimerServicewhen stopping.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt` around lines 364 - 384, Initialize the lateinit timerData state to a zero-valued timer before the stop button can be used, or have stopTimer return the current elapsed time from TimerService and use it in the stop handler. Ensure the btnRunStop listener can safely construct RunToEndRunData even when timerReceiver has not yet delivered a broadcast.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt`:
- Line 100: Update RunActivity.onCreate() to read the retained
RunViewModel.isPaused value and call updatePauseResumeUI() with it before
setUpPauseResume(), ensuring the recreated activity displays the correct
pause/resume state.
- Around line 137-146: In setUpPauseResume, return immediately from the click
listener when timerService is null, before invoking pause/resume or updating
viewModel.isPaused and the UI. Only perform the existing toggle and
updatePauseResumeUI flow after confirming the service is bound.
---
Outside diff comments:
In `@app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt`:
- Around line 364-384: Initialize the lateinit timerData state to a zero-valued
timer before the stop button can be used, or have stopTimer return the current
elapsed time from TimerService and use it in the stop handler. Ensure the
btnRunStop listener can safely construct RunToEndRunData even when timerReceiver
has not yet delivered a broadcast.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 238b5d96-4be4-4e50-8d6e-e5d1a9489214
📒 Files selected for processing (11)
app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.ktapp/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.ktapp/src/main/java/com/runnect/runnect/presentation/run/TimerService.ktapp/src/main/res/drawable/bg_paused_pill.xmlapp/src/main/res/drawable/circle_ghost_button.xmlapp/src/main/res/drawable/circle_m1_button.xmlapp/src/main/res/drawable/ic_run_pause.xmlapp/src/main/res/drawable/ic_run_resume.xmlapp/src/main/res/drawable/ic_run_stop.xmlapp/src/main/res/layout/activity_run.xmlapp/src/main/res/values/strings.xml
| getCurrentLocation() | ||
| showRecord() | ||
| backButton() | ||
| setUpPauseResume() |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate RunActivity.kt =="
fd -a 'RunActivity\.kt$' . || true
echo "== file outline =="
if [ -f app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt ]; then
ast-grep outline app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt --view expanded || true
echo "== relevant lines =="
sed -n '1,220p' app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt | nl -ba -v1
fi
echo "== search RunViewModel isPaused/updatePauseResumeUI == "
rg -n "isPaused|updatePauseResumeUI|setUpPauseResume|ViewModel|onResume|onPause|onStop" app/src/main/java/com/runnect/runnect/presentation/run -S || trueRepository: Runnect/Runnect-Android
Length of output: 3001
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== RunActivity.kt relevant sections =="
sed -n '80,175p' app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt
echo "== RunViewModel references =="
rg -n "class RunViewModel|RunViewModel|isPaused|updatePauseResumeUI|setUpPauseResume" app/src/main/java/com/runnect/runnect/presentation/run -S
echo "== TimerService pause references =="
rg -n "pause|resume|isPaused|broadcast|Pause|Resume" app/src/main/java/com/runnect/runnect -S || trueRepository: Runnect/Runnect-Android
Length of output: 8933
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== RunViewModel.kt =="
sed -n '1,80p' app/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.kt
echo "== timerService pause/resume interaction in TimerService and TimerHandler =="
rg -n "mHandler|mTimerTask|timerService|pause|resume|stop|start" app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt -S
sed -n '1,160p' app/src/main/java/com/runnect/runnect/presentation/run/TimerService.ktRepository: Runnect/Runnect-Android
Length of output: 6404
Render the retained pause state when the activity is recreated.
RunViewModel.isPaused survives configuration changes, but updatePauseResumeUI() only runs from the pause/resume button callback. After rotation during a pause, the recreated UI shows the running icon while the timer service remains paused.
Read isPaused in onCreate() from the retained ViewModel value and call updatePauseResumeUI() for that value before setting up the button listener.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt` at
line 100, Update RunActivity.onCreate() to read the retained
RunViewModel.isPaused value and call updatePauseResumeUI() with it before
setUpPauseResume(), ensuring the recreated activity displays the correct
pause/resume state.
| private fun setUpPauseResume() { | ||
| binding.btnRunPauseResume.setOnClickListener { | ||
| val isPaused = viewModel.isPaused.value ?: false | ||
| if (isPaused) { | ||
| timerService?.resumeTimer() | ||
| } else { | ||
| timerService?.pauseTimer() | ||
| } | ||
| viewModel.isPaused.value = !isPaused | ||
| updatePauseResumeUI(!isPaused) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate files =="
fd -a 'RunActivity\.kt$|TimerService\.kt$' . | sed 's#^\./##'
echo "== RunActivity outline/sections =="
RUNACTIVITY="$(fd 'RunActivity\.kt$' . | head -n1 || true)"
if [ -n "${RUNACTIVITY:-}" ]; then
echo "--- $RUNACTIVITY lines 1-230 ---"
sed -n '1,230p' "$RUNACTIVITY" | nl -ba
fi
echo "== TimerService outline/sections =="
TIMER="$(fd 'TimerService\.kt$' . | head -n1 || true)"
if [ -n "${TIMER:-}" ]; then
echo "--- $TIMER lines 1-260 ---"
sed -n '1,260p' "$TIMER" | nl -ba
fi
echo "== references to timerService/isPaused/pauseResume/updatePauseResumeUI =="
rg -n "timerService|isPaused|pauseResume|updatePauseResumeUI|onServiceConnected|bindService|UnbindService|btnRunPauseResume" app/src/main/java || trueRepository: Runnect/Runnect-Android
Length of output: 628
🏁 Script executed:
#!/bin/bash
set -euo pipefail
RUNACTIVITY="$(fd 'RunActivity\.kt$' . | grep 'app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt' || true)"
TIMER="$(fd 'TimerService\.kt$' . | grep 'app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt' || true)"
echo "== RunActivity relevant sections =="
sed -n '1,240p' "${RUNACTIVITY:-/dev/null}"
echo
echo "== TimerService relevant sections =="
sed -n '1,280p' "${TIMER:-/dev/null}"
echo
echo "== references =="
rg -n "timerService|isPaused|setUpPauseResume|updatePauseResumeUI|onServiceConnected|bindService|bindServiceAsync|UnbindService|btnRunPauseResume|pauseTimer|resumeTimer" app/src/main/java || true
echo
echo "== Kotlin safe-call behavior probe =="
node - <<'JS'
let timerServiceCalled = false;
class TimerService {
pauseTimer() {
timerServiceCalled = true;
console.log("pauseTimer called");
}
resumeTimer() {
console.log("resumeTimer called");
}
}
let timerService = null;
let viewModelIsPaused = false;
// Kotlin safe-call null behavior: if receiver is null, expression returns null and body is skipped.
try {
if (timerService != null) {
timerService.pauseTimer();
}
} catch (e) {
console.error(e);
}
viewModelIsPaused = !viewModelIsPaused;
console.log({timerServiceCalled, viewModelIsPaused});
timerService = new TimerService();
if (timerService != null) {
timerService.resumeTimer();
}
console.log({timerServiceCalled});
JSRepository: Runnect/Runnect-Android
Length of output: 17308
Return before updating pause state if timerService is not bound.
timerService is set in onServiceConnected, but the pause button is available immediately. A tap before binding calls resumeTimer() when the service has not bound, then sets isPaused to true. The next tap calls startTimer() while the existing timer is still active, creating a second schedule and doubling elapsed-time broadcasts.
Check timerService before changing either state or UI.
Proposed fix
private fun setUpPauseResume() {
binding.btnRunPauseResume.setOnClickListener {
+ val service = timerService ?: return@setOnClickListener
val isPaused = viewModel.isPaused.value ?: false
if (isPaused) {
- timerService?.resumeTimer()
+ service.resumeTimer()
} else {
- timerService?.pauseTimer()
+ service.pauseTimer()
}
viewModel.isPaused.value = !isPaused
updatePauseResumeUI(!isPaused)
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private fun setUpPauseResume() { | |
| binding.btnRunPauseResume.setOnClickListener { | |
| val isPaused = viewModel.isPaused.value ?: false | |
| if (isPaused) { | |
| timerService?.resumeTimer() | |
| } else { | |
| timerService?.pauseTimer() | |
| } | |
| viewModel.isPaused.value = !isPaused | |
| updatePauseResumeUI(!isPaused) | |
| private fun setUpPauseResume() { | |
| binding.btnRunPauseResume.setOnClickListener { | |
| val service = timerService ?: return@setOnClickListener | |
| val isPaused = viewModel.isPaused.value ?: false | |
| if (isPaused) { | |
| service.resumeTimer() | |
| } else { | |
| service.pauseTimer() | |
| } | |
| viewModel.isPaused.value = !isPaused | |
| updatePauseResumeUI(!isPaused) | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt` around
lines 137 - 146, In setUpPauseResume, return immediately from the click listener
when timerService is null, before invoking pause/resume or updating
viewModel.isPaused and the UI. Only perform the existing toggle and
updatePauseResumeUI flow after confirming the service is bound.
- onStartCommand에서 startTimer()가 initNotification()보다 먼저 호출되어, 타이머 스레드가 아직 초기화 안 된 lateinit notificationBuilder를 참조해 UninitializedPropertyAccessException 발생 - initNotification()을 startTimer()보다 먼저 호출하도록 순서 변경
|
실기기(에뮬레이터)로 러닝 시작 시 크래시 재현·수정했습니다 (16c5f14). 증상: 러닝 시작 직후 원인: 수정: 수정 후 실기기에서 러닝 시작 시 크래시 없이 정상 진입 확인했습니다. |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt (1)
93-104: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftSerialize timer lifecycle transitions.
pauseTimer()can cancel theTimerwhile itsTimerTaskis still running. The task can overwrite"일시정지 중".resumeTimer()can start a new timer before the cancelled task finishes, which can losetime++updates.
onStartCommand()also callsstartTimer()for every start command. Repeated commands can leave multiple active timers becausestartTimer()does not cancel the previous timer.Use one serialized executor, or protect the complete task body and lifecycle changes with the same lock. Make
startTimer()idempotent.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt` around lines 93 - 104, The timer lifecycle and task execution in pauseTimer(), resumeTimer(), onStartCommand(), and startTimer() are not serialized, allowing stale tasks and duplicate timers. Protect timer cancellation, task execution, notification updates, and time++ with one shared lock or serialized executor; ensure startTimer() cancels or reuses any existing timer before creating another, and prevent a cancelled task from overwriting the paused notification or losing updates during resume.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt`:
- Around line 93-104: The timer lifecycle and task execution in pauseTimer(),
resumeTimer(), onStartCommand(), and startTimer() are not serialized, allowing
stale tasks and duplicate timers. Protect timer cancellation, task execution,
notification updates, and time++ with one shared lock or serialized executor;
ensure startTimer() cancels or reuses any existing timer before creating
another, and prevent a cancelled task from overwriting the paused notification
or losing updates during resume.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 43a41984-4f62-4c49-adf5-05e3b07e86e7
📒 Files selected for processing (1)
app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt
작업 배경
변경 사항
activity_run.xmlRunViewModel.ktisPaused상태 추가TimerService.ktpauseTimer()/resumeTimer()추가 — 기존time값을 초기화하지 않는startTimer/stopTimer구조를 그대로 재사용해 재개 시 멈춘 지점부터 이어서 카운트RunActivity.kt영향 범위
showRecord())는 로직 변경 없이 새 종료 버튼(btn_run_stop)에 그대로 재연결 — 종료 시EndRunActivity로 이동하는 기존 동작은 동일.colors.xml/activity_run.xml토큰만 사용, 신규 컬러 없음)Test Plan
./gradlew :app:compileDebugKotlin,:app:assembleDebug로컬 빌드 성공 확인🤖 Generated with Claude Code
Summary by CodeRabbit