Skip to content

feat: 러닝 화면에 일시정지/재개 기능 추가 - #406

Open
unam98 wants to merge 2 commits into
developfrom
feature/run-pause-resume
Open

feat: 러닝 화면에 일시정지/재개 기능 추가#406
unam98 wants to merge 2 commits into
developfrom
feature/run-pause-resume

Conversation

@unam98

@unam98unam98 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

작업 배경

  • 지금까지 러닝 화면엔 "러닝 종료" 버튼 하나만 있어 러닝 도중 잠시 멈출 방법이 없었음 (NRC 등 다른 러닝 앱엔 있는 기본 기능).

변경 사항

영역내용
activity_run.xml풀와이드 "러닝 종료" 버튼 제거 → 원형 일시정지/재개 버튼(항상 노출) + 원형 종료 버튼(일시정지 중에만 노출)으로 교체, 상단에 "일시정지 중" 배지 추가
RunViewModel.ktisPaused 상태 추가
TimerService.ktpauseTimer()/resumeTimer() 추가 — 기존 time 값을 초기화하지 않는 startTimer/stopTimer 구조를 그대로 재사용해 재개 시 멈춘 지점부터 이어서 카운트
RunActivity.kt일시정지/재개/종료 버튼 클릭 처리, 상태에 따른 버튼 노출 전환
신규 drawable 6개원형 버튼 배경 2종(M1 필/고스트), 아이콘 3종(일시정지/재개/정지), 일시정지 배지 배경

영향 범위

  • 지도는 NRC처럼 별도 통계 화면으로 바꾸지 않고 그대로 유지 — Runnect는 "코스를 그리는" 서비스라 지금까지 그린 경로를 계속 보여주는 게 정체성에 맞다고 판단, 대신 옅은 틴트 + 배지로 일시정지 상태만 표시.
  • 기존 "러닝 종료" 클릭 핸들러(showRecord())는 로직 변경 없이 새 종료 버튼(btn_run_stop)에 그대로 재연결 — 종료 시 EndRunActivity로 이동하는 기존 동작은 동일.
  • 디자인 시안: https://claude.ai/code/artifact/5b40eebb-3e54-400f-b951-07e186cedf28 (기존 colors.xml/activity_run.xml 토큰만 사용, 신규 컬러 없음)

Test Plan

  • ./gradlew :app:compileDebugKotlin, :app:assembleDebug 로컬 빌드 성공 확인
  • 실기기/에뮬레이터 수동 QA 필요 — adb 환경이 없어 이 세션에서는 직접 실행/육안 확인을 하지 못했음. 일시정지→재개 시 타이머가 멈춘 지점부터 이어지는지, 버튼 전환이 의도대로 보이는지 직접 확인 필요.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added pause and resume controls during an active run.
    • Preserves elapsed time when paused and updates the run notification.
    • Added a dedicated stop-run action and paused-state indicator.
    • Updated the running screen with clearer controls, icons, labels, and accessibility text.
    • Repositioned the current-location control to accommodate the new action dock.

기존엔 러닝 종료 버튼 하나만 있어 중간에 멈출 수 없었음. 기존
"러닝 종료" 풀와이드 버튼을 원형 일시정지(주) 버튼으로 교체하고,
일시정지 시에만 종료(정지)/재개 버튼 두 개가 나타나도록 했다.
지도는 NRC처럼 별도 화면으로 바꾸지 않고 그대로 유지하되, 옅은
틴트와 "일시정지 중" 배지로 상태를 표시해 코스 경로를 계속 보여준다.
TimerService에 pauseTimer/resumeTimer를 추가했는데, 기존 time
값을 초기화하지 않는 startTimer/stopTimer 구조를 그대로 재사용해
일시정지 후 재개하면 멈췄던 지점부터 이어서 카운트된다.
@unam98unam98 self-assigned this Aug 6, 2026
@coderabbitai

coderabbitaiBot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The run screen now supports pause and resume actions. TimerService preserves elapsed time during pauses. The UI displays paused state and uses a stop control to complete the run.

Changes

Run pause flow

Layer / File(s)Summary
Timer pause state
app/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.kt, app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt, app/src/main/res/values/strings.xml
RunViewModel exposes isPaused. TimerService initializes its notification before updates, pauses scheduled updates, preserves elapsed time, and resumes from the preserved value.
Run screen controls
app/src/main/res/drawable/*, app/src/main/res/layout/activity_run.xml
The screen adds pause/resume and stop controls, paused-state styling, related icons, and updated location-button constraints.
Activity control wiring
app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt
RunActivity connects pause/resume actions to TimerService, updates state and accessibility text, toggles paused controls, and moves completion handling to btnRunStop.

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
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly and concisely describes the main change: adding pause and resume functionality to the running screen.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/run-pause-resume

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Initialize timerData before stop can use it.

timerData is lateinit and is only assigned by timerReceiver, but the stop path reads it immediately after calling stopTimer(). If stop runs before the first timer broadcast, RunToEndRunData construction accesses an uninitialized property. Initialize timerData to zero time before enabling stop, or pass the current elapsed time from TimerService when 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

📥 Commits

Reviewing files that changed from the base of the PR and between d7cb241 and d2b97df.

📒 Files selected for processing (11)
  • app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt
  • app/src/main/java/com/runnect/runnect/presentation/run/RunViewModel.kt
  • app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt
  • app/src/main/res/drawable/bg_paused_pill.xml
  • app/src/main/res/drawable/circle_ghost_button.xml
  • app/src/main/res/drawable/circle_m1_button.xml
  • app/src/main/res/drawable/ic_run_pause.xml
  • app/src/main/res/drawable/ic_run_resume.xml
  • app/src/main/res/drawable/ic_run_stop.xml
  • app/src/main/res/layout/activity_run.xml
  • app/src/main/res/values/strings.xml

getCurrentLocation()
showRecord()
backButton()
setUpPauseResume()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate RunActivity.kt =="
fd -a 'RunActivity\.kt$'.||trueecho"== 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 ||trueecho"== relevant lines =="
sed -n '1,220p' app/src/main/java/com/runnect/runnect/presentation/run/RunActivity.kt | nl -ba -v1
fiecho"== search RunViewModel isPaused/updatePauseResumeUI == "
rg -n "isPaused|updatePauseResumeUI|setUpPauseResume|ViewModel|onResume|onPause|onStop" app/src/main/java/com/runnect/runnect/presentation/run -S ||true

Repository: Runnect/Runnect-Android

Length of output: 3001


🏁 Script executed:

#!/bin/bashset -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 ||true

Repository: Runnect/Runnect-Android

Length of output: 8933


🏁 Script executed:

#!/bin/bashset -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.kt

Repository: 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.

Comment on lines +137 to +146
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -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:-}" ];thenecho"--- $RUNACTIVITY lines 1-230 ---"
sed -n '1,230p'"$RUNACTIVITY"| nl -ba
fiecho"== TimerService outline/sections =="
TIMER="$(fd 'TimerService\.kt$'.| head -n1 || true)"if [ -n"${TIMER:-}" ];thenecho"--- $TIMER lines 1-260 ---"
sed -n '1,260p'"$TIMER"| nl -ba
fiecho"== references to timerService/isPaused/pauseResume/updatePauseResumeUI =="
rg -n "timerService|isPaused|pauseResume|updatePauseResumeUI|onServiceConnected|bindService|UnbindService|btnRunPauseResume" app/src/main/java ||true

Repository: Runnect/Runnect-Android

Length of output: 628


🏁 Script executed:

#!/bin/bashset -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}"echoecho"== TimerService relevant sections =="
sed -n '1,280p'"${TIMER:-/dev/null}"echoecho"== references =="
rg -n "timerService|isPaused|setUpPauseResume|updatePauseResumeUI|onServiceConnected|bindService|bindServiceAsync|UnbindService|btnRunPauseResume|pauseTimer|resumeTimer" app/src/main/java ||trueechoecho"== 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});JS

Repository: 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.

Suggested change
privatefunsetUpPauseResume() {
binding.btnRunPauseResume.setOnClickListener {
val isPaused = viewModel.isPaused.value ?:false
if (isPaused) {
timerService?.resumeTimer()
} else {
timerService?.pauseTimer()
}
viewModel.isPaused.value =!isPaused
updatePauseResumeUI(!isPaused)
privatefunsetUpPauseResume() {
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()보다 먼저 호출하도록 순서 변경
@unam98

Copy link
Copy Markdown
CollaboratorAuthor

실기기(에뮬레이터)로 러닝 시작 시 크래시 재현·수정했습니다 (16c5f14).

증상: 러닝 시작 직후 TimerService에서 kotlin.UninitializedPropertyAccessException: lateinit property notificationBuilder has not been initialized 크래시

원인: onStartCommand()에서 startTimer()initNotification()보다 먼저 호출됨. 타이머는 delay=0으로 즉시 백그라운드 스레드에서 틱이 돌기 시작하는데, notificationBuilder는 그 뒤에 실행되는 initNotification()에서 초기화되다 보니 타이밍에 따라 레이스 컨디션으로 크래시 발생. 이 PR이 만든 회귀는 아니고(순서 자체는 마이그레이션 이전부터 동일) pauseTimer()/resumeTimer()를 실제로 검증하려는 과정에서 발견했습니다.

수정: initNotification()startTimer() 순서로 교체.

수정 후 실기기에서 러닝 시작 시 크래시 없이 정상 진입 확인했습니다.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lift

Serialize timer lifecycle transitions.

pauseTimer() can cancel the Timer while its TimerTask is still running. The task can overwrite "일시정지 중". resumeTimer() can start a new timer before the cancelled task finishes, which can lose time++ updates.

onStartCommand() also calls startTimer() for every start command. Repeated commands can leave multiple active timers because startTimer() 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

📥 Commits

Reviewing files that changed from the base of the PR and between d2b97df and 16c5f14.

📒 Files selected for processing (1)
  • app/src/main/java/com/runnect/runnect/presentation/run/TimerService.kt

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@unam98