Project 14: Fitness Tracker with Sensors and a Chart

Android Development with Kotlin: 21 Real Projects · More Real-World Projects · 16 min read · Updated

Total minus baseline

  1. The sensor reports total steps since the phone started.
  2. The app keeps the total it saw at the start of the day.
  3. Today's steps are the difference. A restart resets both numbers.
  4. Room saves one row per day and the chart draws the last seven.

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 callbackFlow does 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: drawRect for bars and drawLine for 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

  1. New Empty Activity project Fitness, package com.example.fitness.
  2. Add Room and KSP from the Project 14 block and sync.
  3. Add the permission lines from AndroidManifest-additions.xml to the manifest.
  4. 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 - baseline as today's steps.
  • progress(steps, goal) returns a value between 0 and 1 for the progress bar.
  • BarChart finds 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

  1. Build the app on a real phone, allow the permission and walk.
  2. Add a setting for the daily goal and store it with DataStore.
  3. Add a notification when the goal is reached.
  4. 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.

Frequently asked questions

Why does the sensor give a total instead of today's steps?

It is a counter kept by the hardware to save battery. Your app turns it into per-day numbers by remembering where each day started.

Do I need a chart library?

Not for simple charts. Canvas is enough for bars and lines. Libraries help with axes, touch and animation in larger apps.

What is Health Connect?

An Android feature where health apps share data with the user's permission. Use it when you need data from other apps or devices.