Phones contain many sensors. This project uses the step counter, a low-power sensor that counts steps even when your app is closed. It reports one number: the total steps since the phone last restarted. To show today's steps you must save a starting point for the day and subtract it. That small piece of thinking is the heart of this app.
You will also draw your own chart with a Canvas. Charts are common in fitness, finance and health apps, and a bar chart needs only a few lines of drawing code. For serious health data across apps, Google offers Health Connect, which you can study next.
Concepts in this project
- SensorManager and SensorEventListener: register to get values, and always unregister. A
callbackFlowdoes both for you. - Runtime permission: from Android 10 the step counter needs
ACTIVITY_RECOGNITION, requested with the Activity Result API. - Baseline: today's steps = the sensor total minus the total at the start of today. If the phone restarts, the sensor total resets, so the baseline resets too.
- Room by day: one row per date key like 2026-11-04, updated as steps arrive.
- Canvas drawing:
drawRectfor bars anddrawLinefor the goal line, scaled by the largest value. - Graceful absence: some phones have no step counter. The screen says so instead of failing.
Create the project
- New Empty Activity project
Fitness, packagecom.example.fitness. - Add Room and KSP from the Project 14 block and sync.
- Add the permission lines from
AndroidManifest-additions.xmlto the manifest. - Create the files below. Test on a real phone, since emulators have no real step counter.
`Gradle dependencies for projects 11 to 21 (use the block for this project)`
// ---- Project 11 Calculator: no extra libraries. Test dependency:
testImplementation("junit:junit:4.13.2")
// ---- Project 12 E-commerce
plugins { id("com.google.devtools.ksp"); kotlin("plugin.serialization") }
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// ---- Project 13 Chat (Firebase)
// project-level plugins: id("com.google.gms.google-services")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-messaging")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
// Also put google-services.json (from your Firebase console) in the app folder.
// ---- Project 14 Fitness
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// No library for the sensor: it is part of Android.
// ---- Project 15 Food delivery
implementation("com.google.maps.android:maps-compose:6.2.1")
implementation("com.google.android.gms:play-services-maps:19.0.0")
// Manifest, inside <application>:
// <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_KEY"/>
// Keep the key out of public git: read it from local.properties with the Secrets Gradle Plugin.
// ---- Project 16 Music player
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 17 QR scanner
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:barcode-scanning:17.3.0")
// ---- Project 18 Banking
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// ---- Project 19 AI chatbot
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
// ---- Project 20 Video streaming
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 21 Job portal
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
Fitness/app/src/main/java/com/example/fitness/
StepSensor.kt sensor as a Flow
FitnessDb.kt DaySteps entity, DAO, database
FitnessViewModel.kt start tracking, last week, goal progress
FitnessScreen.kt permission, progress, Canvas chart
MainActivity.kt
The code
`app/src/main/AndroidManifest.xml (add these lines)`
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-feature android:name="android.hardware.sensor.stepcounter" android:required="false" />
`app/src/main/java/com/example/fitness/StepSensor.kt`
package com.example.fitness
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
// The step counter sensor reports total steps since the phone started.
// This Flow emits that total while someone collects it, and unregisters when they stop.
class StepSensor(context: Context) {
private val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val sensor: Sensor? = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
val available: Boolean get() = sensor != null
fun totals(): Flow<Float> = callbackFlow {
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
trySend(event.values[0])
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
}
}
if (sensor != null) {
manager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_UI)
}
awaitClose { manager.unregisterListener(listener) }
}
}
`app/src/main/java/com/example/fitness/FitnessDb.kt`
package com.example.fitness
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
// one row for each day: date key like 2026-11-04
@Entity(tableName = "day_steps")
data class DaySteps(
@PrimaryKey val day: String,
val steps: Int,
val baseline: Float,
)
@Dao
interface StepsDao {
@Query("SELECT * FROM day_steps ORDER BY day DESC LIMIT 7")
fun lastWeek(): Flow<List<DaySteps>>
@Query("SELECT * FROM day_steps WHERE day = :day")
suspend fun forDay(day: String): DaySteps?
@Upsert
suspend fun save(row: DaySteps)
}
@Database(entities = [DaySteps::class], version = 1, exportSchema = false)
abstract class FitnessDatabase : RoomDatabase() {
abstract fun steps(): StepsDao
}
`app/src/main/java/com/example/fitness/FitnessViewModel.kt`
package com.example.fitness
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.time.LocalDate
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class FitnessViewModel(
private val sensor: StepSensor,
private val dao: StepsDao,
) : ViewModel() {
val sensorAvailable: Boolean get() = sensor.available
val goal = 8000
val week: StateFlow<List<DaySteps>> = dao.lastWeek()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private var tracking: Job? = null
// Call after the user allowed activity recognition (Android 10 and newer).
fun startTracking() {
if (tracking != null || !sensor.available) return
tracking = viewModelScope.launch {
sensor.totals().collect { total ->
val today = LocalDate.now().toString()
val row = dao.forDay(today) ?: DaySteps(today, 0, total)
// if the phone restarted, the total is lower than the saved baseline
val baseline = if (total < row.baseline) 0f else row.baseline
dao.save(row.copy(steps = (total - baseline).toInt(), baseline = baseline))
}
}
}
override fun onCleared() {
tracking?.cancel()
}
}
fun progress(steps: Int, goal: Int): Float {
if (goal <= 0) return 0f
return (steps.toFloat() / goal).coerceIn(0f, 1f)
}
`app/src/main/java/com/example/fitness/FitnessScreen.kt`
package com.example.fitness
import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.unit.dp
@Composable
fun FitnessScreen(vm: FitnessViewModel) {
val week by vm.week.collectAsState()
val today = week.firstOrNull()?.steps ?: 0
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) vm.startTracking()
}
// The permission is only needed from Android 10; older versions start at once.
LaunchedEffect(Unit) {
if (android.os.Build.VERSION.SDK_INT >= 29) {
launcher.launch(Manifest.permission.ACTIVITY_RECOGNITION)
} else {
vm.startTracking()
}
}
Column(
Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Steps today", style = MaterialTheme.typography.titleMedium)
Text(today.toString(), style = MaterialTheme.typography.displayMedium)
LinearProgressIndicator(progress = { progress(today, vm.goal) }, modifier = Modifier.fillMaxWidth())
Text("Goal: " + vm.goal + " steps")
if (!vm.sensorAvailable) {
Text("This phone has no step counter sensor.", color = MaterialTheme.colorScheme.error)
}
Text("Last 7 days", style = MaterialTheme.typography.titleMedium)
BarChart(week.reversed().map { it.steps }, vm.goal)
Button(onClick = { launcher.launch(Manifest.permission.ACTIVITY_RECOGNITION) }) {
Text("Allow step counting")
}
}
}
@Composable
fun BarChart(values: List<Int>, goal: Int) {
val barColor = MaterialTheme.colorScheme.primary
val lineColor = MaterialTheme.colorScheme.tertiary
Canvas(
Modifier
.fillMaxWidth()
.height(160.dp),
) {
if (values.isEmpty()) return@Canvas
val maxValue = maxOf(values.max(), goal).coerceAtLeast(1).toFloat()
val slot = size.width / values.size
val barWidth = slot * 0.6f
values.forEachIndexed { index, v ->
val h = size.height * (v / maxValue)
drawRect(
color = barColor,
topLeft = Offset(index * slot + (slot - barWidth) / 2, size.height - h),
size = Size(barWidth, h),
)
}
val goalY = size.height * (1 - goal / maxValue)
drawLine(lineColor, Offset(0f, goalY), Offset(size.width, goalY), strokeWidth = 3f)
}
}
`app/src/main/java/com/example/fitness/MainActivity.kt`
package com.example.fitness
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.room.Room
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val db = Room.databaseBuilder(applicationContext, FitnessDatabase::class.java, "fitness.db").build()
val sensor = StepSensor(applicationContext)
setContent {
MaterialTheme {
val vm: FitnessViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return FitnessViewModel(sensor, db.steps()) as T
}
},
)
FitnessScreen(vm)
}
}
}
}
How it works
StepSensor.totals()registers a listener when someone starts collecting and unregisters when they stop, so the sensor is not left running.- The ViewModel collects the flow, looks up today's row and saves
total - baselineas today's steps. progress(steps, goal)returns a value between 0 and 1 for the progress bar.BarChartfinds the largest value (or the goal) and scales each bar to the canvas height. The goal appears as a horizontal line.
Expected output
On first launch Android asks for permission to recognise physical activity. Allow it. The big number shows steps today, starting at 0, and a progress bar shows the part of the 8,000 goal. Walk about 50 steps with the phone: after a few seconds the number rises. The chart shows up to seven bars, one for each day with data, and a line for the goal. On a phone with no step counter, the screen shows "This phone has no step counter sensor." Denying the permission leaves the number at 0 and the Allow step counting button lets you try again.
Your task
- Build the app on a real phone, allow the permission and walk.
- Add a setting for the daily goal and store it with DataStore.
- Add a notification when the goal is reached.
- Read the steps from Health Connect instead and compare the numbers.
How to check it worked
- The number rises while you walk.
- The sensor stops when you leave the screen.
- You can explain why a baseline is needed.