Your assignment: Focus Countdown Timer
Build a study timer with duration choices, Start/Pause/Resume/Reset controls, lifecycle-aware pausing, and a countdown based on elapsed time.
Intermediate · 3–4 hours of building
Before you begin
- Understand Kotlin classes, enums, data classes, and nullable properties.
- Know Compose state and event callbacks.
- Be ready to learn ViewModel, StateFlow, coroutine jobs, and Android lifecycle events.
By the end, you can…
- Model a screen as immutable state and named phases.
- Keep timer logic in a ViewModel and collect it with lifecycle awareness.
- Own and cancel one coroutine job without launching duplicate countdowns.
- Calculate remaining time from a monotonic clock instead of counting callbacks.
- Pause on lifecycle ON_STOP and explain configuration changes versus durable persistence.
- Test rounding and nonnegative countdown rules on the JVM.
Required features
- Provide 10-second demo, 1-minute, 5-minute, and 25-minute durations.
- Implement Start, Pause, Resume, and Reset with clear enabled states.
- Display MM:SS and progress, never a negative remaining time.
- Show a completion message at zero.
- Pause whenever the activity stops, including on rotation; retain remaining time and resume only after the user taps Resume.
- Keep a single countdown job; no background alarm, notification, service, or internet permission is needed.
Set up the project and run the reference app
- Open: Extract the ZIP. In Android Studio choose Open, then select the project folder containing
settings.gradle.kts. Open that folder rather than just a Kotlin file. - Prepare: Use Android Studio’s bundled JDK 17 or 21 in Gradle settings. In SDK Manager install Android SDK Platform 36 and any Build Tools requested during sync.
- Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
- Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the
apprun configuration and press Run. - Observe: Try the normal and boundary scenarios from Test the app. Record what changes after each action. Use Logcat to investigate a crash instead of changing several files at once.
- Practice: Copy the extracted folder to a working folder. Keep the build files, manifest, resources and
CommonUi.kt. Rebuild the application files following the steps below. Attempt each task before consulting its reference.
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTestKeep the toolchain consistent
TOOLCHAIN.md for setup details.Understand the topics before coding
For each topic, connect the Kotlin concept to a visible behavior. Identify the value the screen reads and the event that can change that value.
A small state machine
Ready, Running, Paused, and Complete describe the timer's allowed phases. TimerState groups the selected duration, remaining milliseconds, and phase into one snapshot. The UI derives its button text and enabled states from that snapshot so unrelated Boolean flags cannot contradict one another.
ViewModel and saved state
A ViewModel survives activity recreation caused by rotation. SavedStateHandle also lets Android restore a small timer snapshot after system-managed process recreation when saved instance state is available. It is not a durable database: force-stop, clearing data, or starting a fresh task can reset the timer. Restored in-progress sessions become paused.
Monotonic time and coroutine scheduling
delay(100) schedules another update, but it does not promise an exact 100 ms interval. The timer therefore calculates deadline minus SystemClock.elapsedRealtime at each update. elapsedRealtime measures time since boot and is unaffected by wall-clock changes; a delayed frame changes display freshness without accumulating countdown drift.
Structured coroutine ownership
viewModelScope owns the countdown job and cancels it when the ViewModel is cleared. A Job reference prevents starting a second countdown. Pause and Reset cancel the current job. This is different from launching a coroutine in a composable body, which could repeat work during recomposition.
Lifecycle effects
DisposableEffect attaches a lifecycle observer while the screen exists and removes it during disposal. ON_STOP pauses the timer when the activity leaves the foreground. Rotation also stops the old activity, so this assignment intentionally pauses at rotation and asks the learner to resume explicitly. collectAsStateWithLifecycle controls observation; it does not cancel ViewModel work by itself.
Presentation rules and testing
Milliseconds are rounded up for display so a just-started 10-second session reads 00:10. Internal time remains in milliseconds, and the displayed seconds are derived. Pure formatting and clamping functions can run in a JVM test without an emulator, while lifecycle and button behavior still require an Android device check.
Three different lifetimes
rememberSaveable restores small supported values after Activity recreation; a ViewModel retains screen state during configuration changes. Neither replaces permanent storage. The notes project uses Room for records that must remain after closing and reopening the app.Build the app step by step
Work in this order. The code panels are focused excerpts from the complete files below. They may begin or end inside a function: use the full source browser when copying a file, including its imports. After every step, run the app and check the stated result.
Step 1. 1. Launch the timer and establish its behavior
Your task: Open focus-timer in Android Studio, sync, and run on an API 23+ device. Read the duration chips, status, action buttons, and background-policy note. Choose the 10-second demo to make repeated checks quick. Inspect the activity's MiniTheme and FocusTimer entry point.
Open: app/src/main/java/com/mastercoding/miniprojects/timer/MainActivity.kt
How it works: Before writing asynchronous code, define the observable behavior: Start counts down; Pause freezes; Resume continues; Reset restores the selected duration. Backgrounding and rotating both pause in this project. A precise behavior contract makes later lifecycle decisions testable.
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MiniTheme { FocusTimer() } }
}
}
@Composable
private fun FocusTimer(timer: TimerViewModel = viewModel()) {
val state by timer.state.collectAsStateWithLifecycle()
val owner = LocalLifecycleOwner.current
DisposableEffect(owner, timer) {
val observer = LifecycleEventObserver { _, event ->
// Product decision: leaving the screen pauses a session, including a rotation.
if (event == Lifecycle.Event.ON_STOP) timer.pause()
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
MiniScreen(stringResource(R.string.timer_title)) {
Text(stringResource(R.string.timer_instruction))
val choices = listOf(
10 to R.string.duration_demo,
60 to R.string.duration_one,Stop and check
Step 2. 2. Model phases and one immutable snapshot
Your task: In TimerViewModel.kt, read TimerPhase and TimerState. Sketch the permitted transitions on paper: Ready→Running, Running→Paused or Complete, Paused→Running, and any phase→Ready through Reset. Find the allowedDurations set and explain why the ViewModel validates inputs even though the screen only offers valid chips.
Open: app/src/main/java/com/mastercoding/miniprojects/timer/TimerViewModel.kt
How it works: An enum names mutually exclusive phases. Data-class copy creates a new snapshot while retaining unchanged fields. Validation belongs in the state owner as well as in the UI because a future screen, test, or accessibility action can call these functions directly.
import kotlinx.coroutines.launch
enum class TimerPhase { Ready, Running, Paused, Complete }
data class TimerState(
val durationSeconds: Int = 25 * 60,
val remainingMillis: Long = 25 * 60 * 1_000L,
val phase: TimerPhase = TimerPhase.Ready,
)
class TimerViewModel(private val savedState: SavedStateHandle) : ViewModel() {
private val allowedDurations = setOf(10, 60, 300, 1500)
private val restoredDuration = savedState.get<Int>("duration")
?.takeIf { it in allowedDurations } ?: 1500
private val restoredRemaining = (savedState.get<Long>("remaining") ?: restoredDuration * 1_000L)
.coerceIn(0L, restoredDuration * 1_000L)
private val _state = MutableStateFlow(
TimerState(
durationSeconds = restoredDuration,
remainingMillis = restoredRemaining,
phase = when {
restoredRemaining == 0L -> TimerPhase.Complete
savedState.get<Boolean>("started") == true -> TimerPhase.Paused
else -> TimerPhase.Ready
},
),Stop and check
Step 3. 3. Calculate and format remaining time
Your task: Open TimerMath.kt. Calculate remainingUntil(1000, 750), remainingUntil(1000, 1050), formatRemaining(9999), and formatRemaining(1) by hand. Run the provided TimerMathTest from Android Studio or with gradle testDebugUnitTest when Gradle is installed.
Open: app/src/main/java/com/mastercoding/miniprojects/timer/TimerMath.kt
How it works: Clamping at zero prevents negative countdowns after a delayed update. Adding 999 before integer division rounds positive milliseconds upward to whole seconds. Formatting uses Locale.ROOT to keep the training examples predictable. These pure rules are small enough to verify independently from coroutines and Android lifecycle.
package com.mastercoding.miniprojects.timer
fun remainingUntil(deadlineMillis: Long, nowMillis: Long): Long =
(deadlineMillis - nowMillis).coerceAtLeast(0L)
// Round up: a freshly started 10-second timer should still display 00:10.
fun formatRemaining(millis: Long): String {
val seconds = (millis.coerceAtLeast(0L) + 999L) / 1_000L
return "%02d:%02d".format(java.util.Locale.ROOT, seconds / 60, seconds % 60)
}Stop and check
Step 4. 4. Own exactly one coroutine countdown
Your task: Read startOrResume. Follow the active-job guard, deadline calculation, launch, state publication, and delay. Tap Start repeatedly and observe that the timer completes in roughly ten elapsed seconds rather than accelerating. Do not move launch into the composable body.
Open: app/src/main/java/com/mastercoding/miniprojects/timer/TimerViewModel.kt
How it works: The deadline is captured once for a run. Each tick asks how much real monotonic time remains rather than subtracting a fixed amount per callback. The Job guard makes duplicate events harmless. viewModelScope keeps this work tied to a state owner whose lifetime is longer than one composition.
}
fun startOrResume() {
if (ticker?.isActive == true || state.value.remainingMillis <= 0L) return
deadlineMillis = SystemClock.elapsedRealtime() + state.value.remainingMillis
publish(state.value.copy(phase = TimerPhase.Running))
// This is the only place that launches a countdown. Repeated Start events are ignored.
ticker = viewModelScope.launch {
while (true) {
val remaining = remainingUntil(deadlineMillis, SystemClock.elapsedRealtime())
publish(state.value.copy(
remainingMillis = remaining,
phase = if (remaining == 0L) TimerPhase.Complete else TimerPhase.Running,
))
if (remaining == 0L) break
delay(100L)
}
}
}
fun pause() {
if (state.value.phase != TimerPhase.Running) return
val remaining = remainingUntil(deadlineMillis, SystemClock.elapsedRealtime())
ticker?.cancel()
ticker = null
publish(state.value.copy(Stop and check
Step 5. 5. Implement Pause, Resume, and Reset
Your task: Read pause and reset. Start the demo, pause after about three seconds, wait five seconds, then resume. Reset while running and repeat the same check with the one-minute choice. Inspect why Pause recomputes remaining time before canceling the job.
Open: app/src/main/java/com/mastercoding/miniprojects/timer/TimerViewModel.kt
How it works: Pause records the actual remaining duration at the moment of the event. Resume creates a new deadline from that saved duration, so time spent paused does not count. Reset cancels work and creates a Ready snapshot for the selected duration; it is not implemented as another countdown job.
}
fun pause() {
if (state.value.phase != TimerPhase.Running) return
val remaining = remainingUntil(deadlineMillis, SystemClock.elapsedRealtime())
ticker?.cancel()
ticker = null
publish(state.value.copy(
remainingMillis = remaining,
phase = if (remaining == 0L) TimerPhase.Complete else TimerPhase.Paused,
))
}
fun reset() {
ticker?.cancel()
ticker = null
val seconds = state.value.durationSeconds
publish(TimerState(seconds, seconds * 1_000L))
}
private fun publish(next: TimerState) {
_state.value = next
savedState["duration"] = next.durationSeconds
savedState["remaining"] = next.remainingMillis
savedState["started"] = next.phase != TimerPhase.Ready
}Stop and check
Step 6. 6. Render state without owning the timer job
Your task: In FocusTimer, find collectAsStateWithLifecycle and trace how phase selects the status text and main button label. Inspect the progress calculation. Increase font size and test a narrow emulator; scroll the screen to reach every action.
Open: app/src/main/java/com/mastercoding/miniprojects/timer/MainActivity.kt
How it works: The composable observes data and sends user events; it does not manage countdown timing. StateFlow supplies the latest snapshot, and lifecycle-aware collection avoids unnecessary UI observation in stopped screens. Progress is derived from duration and remaining time, preventing a second progress variable from drifting out of sync.
@Composable
private fun FocusTimer(timer: TimerViewModel = viewModel()) {
val state by timer.state.collectAsStateWithLifecycle()
val owner = LocalLifecycleOwner.current
DisposableEffect(owner, timer) {
val observer = LifecycleEventObserver { _, event ->
// Product decision: leaving the screen pauses a session, including a rotation.
if (event == Lifecycle.Event.ON_STOP) timer.pause()
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
MiniScreen(stringResource(R.string.timer_title)) {
Text(stringResource(R.string.timer_instruction))
val choices = listOf(
10 to R.string.duration_demo,
60 to R.string.duration_one,
300 to R.string.duration_five,
1500 to R.string.duration_twenty_five,
)
choices.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { (seconds, label) ->
FilterChip(
selected = state.durationSeconds == seconds,Stop and check
Step 7. 7. Attach and remove the lifecycle observer
Your task: Find DisposableEffect. Start the demo, press Home, wait, and return. Then run another session and rotate the emulator. Explain why ON_STOP calls timer.pause and why onDispose removes the observer. Press Resume after each return.
Open: app/src/main/java/com/mastercoding/miniprojects/timer/MainActivity.kt
How it works: Coroutine ownership and foreground behavior are separate concerns: the ViewModel survives rotation and could keep running unless explicitly paused. The observer enforces this project's foreground-only policy. Removing it avoids keeping an obsolete activity owner attached after the composition is disposed.
val state by timer.state.collectAsStateWithLifecycle()
val owner = LocalLifecycleOwner.current
DisposableEffect(owner, timer) {
val observer = LifecycleEventObserver { _, event ->
// Product decision: leaving the screen pauses a session, including a rotation.
if (event == Lifecycle.Event.ON_STOP) timer.pause()
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
MiniScreen(stringResource(R.string.timer_title)) {
Text(stringResource(R.string.timer_instruction))
val choices = listOf(
10 to R.string.duration_demo,
60 to R.string.duration_one,
300 to R.string.duration_five,
1500 to R.string.duration_twenty_five,
)
choices.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { (seconds, label) ->
FilterChip(
selected = state.durationSeconds == seconds,
enabled = state.phase != TimerPhase.Running,
onClick = { timer.chooseDuration(seconds) },
label = { Text(stringResource(label)) },Stop and check
Step 8. 8. Inspect restoration and finish your evidence
Your task: Read publish and the restored state initialization. Pause the timer, press Home, and run adb shell am kill com.mastercoding.miniprojects.timer from a terminal with platform-tools available, then return through Recents if the task remains. Record what your device restores. Submit the source, phase-transition sketch, JVM test result, and screenshots for paused and complete states.
Open: app/src/main/java/com/mastercoding/miniprojects/timer/TimerViewModel.kt
How it works: SavedStateHandle stores only small reconstruction values. This is best-effort Android saved-state restoration, not a promise that every fresh launch continues a timer. A system-restored in-progress snapshot is deliberately paused, avoiding accidental background execution. Distinguishing rotation, process recreation, and force-stop is part of the exercise.
}
private fun publish(next: TimerState) {
_state.value = next
savedState["duration"] = next.durationSeconds
savedState["remaining"] = next.remainingMillis
savedState["started"] = next.phase != TimerPhase.Ready
}
}Stop and check
Complete source files
Every text file in the ZIP is available here, including Kotlin, resources, Gradle scripts and the student README. The binary Gradle wrapper JAR is included in the ZIP. Source excerpts and downloads are generated from these same files.
| Start here | Responsibility |
|---|---|
| MainActivity.kt | The launcher Activity installs the Compose UI with setContent. |
| CommonUi.kt | Material 3 theme and reusable screen container with scrolling and system insets. |
| Application Kotlin files | State, rules, navigation or persistence introduced in the guide. |
| app/src/main/res | Images, text resources, and the launch theme. |
| app/build.gradle.kts | SDK settings and the exact libraries used by this app. |
| README.md / TOOLCHAIN.md | Student instructions and setup, usable without the website. |
Android launcher Activity, setContent entry point and screen composition. Start tracing the app here.
21 complete text files. Copy the selected file with the code panel button, or download the full project above.
package com.mastercoding.miniprojects.timer
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MiniTheme { FocusTimer() } }
}
}
@Composable
private fun FocusTimer(timer: TimerViewModel = viewModel()) {
val state by timer.state.collectAsStateWithLifecycle()
val owner = LocalLifecycleOwner.current
DisposableEffect(owner, timer) {
val observer = LifecycleEventObserver { _, event ->
// Product decision: leaving the screen pauses a session, including a rotation.
if (event == Lifecycle.Event.ON_STOP) timer.pause()
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
MiniScreen(stringResource(R.string.timer_title)) {
Text(stringResource(R.string.timer_instruction))
val choices = listOf(
10 to R.string.duration_demo,
60 to R.string.duration_one,
300 to R.string.duration_five,
1500 to R.string.duration_twenty_five,
)
choices.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { (seconds, label) ->
FilterChip(
selected = state.durationSeconds == seconds,
enabled = state.phase != TimerPhase.Running,
onClick = { timer.chooseDuration(seconds) },
label = { Text(stringResource(label)) },
)
}
}
}
Text(formatRemaining(state.remainingMillis), style = MaterialTheme.typography.displayLarge)
LinearProgressIndicator(
progress = { (1f - state.remainingMillis.toFloat() / (state.durationSeconds * 1_000f)).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
)
Text(stringResource(when (state.phase) {
TimerPhase.Ready -> R.string.timer_ready
TimerPhase.Running -> R.string.timer_running
TimerPhase.Paused -> R.string.timer_paused
TimerPhase.Complete -> R.string.timer_complete
}))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
enabled = state.phase != TimerPhase.Complete,
onClick = {
if (state.phase == TimerPhase.Running) timer.pause() else timer.startOrResume()
},
) {
Text(stringResource(when (state.phase) {
TimerPhase.Running -> R.string.pause
TimerPhase.Paused -> R.string.resume
else -> R.string.start
}))
}
OutlinedButton(onClick = timer::reset) { Text(stringResource(R.string.reset)) }
}
Text(stringResource(R.string.background_policy), style = MaterialTheme.typography.bodySmall)
}
}Test the app with concrete scenarios
Run these scenarios on your device or emulator. Write down the actual result beside the expected result. A successful Gradle build checks compilation; it does not demonstrate tapping, accessibility, keyboard behavior or rotation.
| Do this | Expected result |
|---|---|
| Choose every offered duration before starting. | The screen shows 00:10, 01:00, 05:00, and 25:00 respectively. |
| Rapidly tap Start on the 10-second demo. | One timer runs; it does not accelerate or finish early because of duplicate jobs. |
| Pause a running session and wait five seconds. | The displayed remaining time stays fixed and Resume continues from the paused duration. |
| Reset while running and while paused. | The job is canceled, the chosen duration is restored, and the phase becomes Ready. |
| Let the demo run to completion and wait several more seconds. | The screen stays at 00:00 with a completion message; it never displays negative time. |
| Press Home during a run, wait, and return. | The timer is paused, and elapsed background time is not deducted. |
| Rotate during a run. | Selected duration and remaining time are retained, and the timer intentionally shows Paused until Resume is tapped. |
| Change the device wall clock during a foreground run, if your test device allows it. | The countdown is unaffected because it uses elapsedRealtime instead of currentTimeMillis. |
| Run TimerMathTest. | Deadline clamping, near-zero display, and minute-boundary formatting all pass. |
Your completion checklist
Check an item only after demonstrating it. Progress is saved in this browser.
Troubleshooting: understand the cause
The countdown speeds up after tapping Start several times.
Cause: Multiple coroutines were launched without guarding the active Job.
Repair: Keep one ticker reference and return when ticker.isActive is true; cancel it on Pause and Reset.
The timer slowly drifts behind real elapsed time.
Cause: The implementation subtracts one second for every delay callback and assumes callbacks arrive on time.
Repair: Calculate a monotonic deadline once and derive remainingUntil(deadline, elapsedRealtime()) on every tick.
The timer keeps running after the user leaves the app.
Cause: Lifecycle-aware collection was mistaken for cancellation of ViewModel work.
Repair: Keep the explicit ON_STOP observer; collecting less often does not stop the producer coroutine.
A fresh launch is expected to restore a timer after force-stop or data clearing.
Cause: SavedStateHandle was treated as permanent storage.
Repair: Document its saved-instance-state scope. Add Room or DataStore only if the product requires durable session history.
Submit your app and explain your decisions
- Submit the Android Studio project without build caches,
.gradleor machine-specificlocal.properties. - Include two screenshots demonstrating normal behavior and an edge case.
- Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
- Answer the questions below using function and state names from your own implementation.
- Why does delay(100) not guarantee an exact update every 100 ms, and how does the deadline approach prevent accumulated drift?
- What happens to this timer on recomposition, rotation, Home, process recreation, and force-stop? Explain each separately.
- If the requirement changed to notify the user while the app is closed, why would a ViewModel coroutine alone be insufficient?
| Criterion | Points | What to demonstrate |
|---|---|---|
| Core behavior | 40 | All required actions give the expected result. |
| Validation and lifecycle | 20 | Invalid inputs, empty states, rotation and the project's persistence rules work. |
| Interface and accessibility | 20 | Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets. |
| Code and explanation | 20 | Small named functions, clear state ownership and a correct explanation of one event-to-result trace. |
Extra challenges after the core works
- Add focus/break session phases while retaining one job owner.
- Add a circular progress indicator and an accessible text equivalent.
- Inject a clock into the ViewModel and use kotlinx-coroutines-test to test Pause and Resume deterministically.
- Design a separate alarm-based version only after studying exact-alarm permissions and background execution; describe how its behavior differs from this foreground timer.
Choose one extension, write its acceptance check first, then implement it without breaking the original scenarios.