Project 9: Habit Tracker with DataStore and Notifications

Android Development with Kotlin: 21 Real Projects · Advanced Apps and Release · 12 min read · Updated

Saved data and reminders

  1. The user presses Done. The date is added to that habit.
  2. DataStore keeps the data on the phone and sends it back as a Flow.
  3. WorkManager runs the reminder job about once a day, even if the app is closed.
  4. The job posts a notification, if the user allowed it.

Habit trackers show the features that make an app feel alive: it remembers your settings, it keeps a streak and it reminds you at the right time even when closed. This project covers three tools: DataStore for small saved data, WorkManager for background work, and notifications with the permission needed on newer Android versions.

Concepts in this project

  • DataStore: the modern replacement for SharedPreferences. It stores small data asynchronously and exposes it as a Flow. This project stores the habit list as JSON text in one key.
  • Streak logic: a pure function counts consecutive days ending today (or yesterday) from a set of completed dates.
  • WorkManager: runs work that must happen even if the app is closed or the phone restarts. A periodic request is not exact, which suits reminders.
  • Notification channel: from Android 8 every notification belongs to a channel the user can control.
  • Runtime permission: from Android 13 the app must ask for POST_NOTIFICATIONS. Use the activity result API from Compose, and handle a refusal gracefully.

Create the project

  1. New Empty Activity project Habits, package com.example.habits.
  2. Add DataStore, WorkManager, serialization and lifecycle libraries, then sync.
  3. Add the permission lines from AndroidManifest-additions.xml to the manifest.
  4. Create the files below.

`Gradle additions (DataStore, WorkManager)`

// Project 8 (posts reader), on top of the Retrofit and serialization lines from project 6:
implementation("io.coil-kt:coil-compose:2.7.0")                 // AsyncImage
implementation("androidx.navigation:navigation-compose:2.8.5")

// Project 9 (habit tracker):
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// plugins block: id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"

`app/src/main/AndroidManifest.xml (add these lines)`

<!-- Add this line inside <manifest>, above <application>. It is only asked at run time on Android 13 and newer. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
Habits/app/src/main/java/com/example/habits/
  HabitStore.kt      DataStore + JSON + streak maths
  HabitViewModel.kt
  ReminderWorker.kt  WorkManager worker and notification
  HabitScreen.kt     list, add, today check, permission
  MainActivity.kt

The code

`app/src/main/java/com/example/habits/HabitStore.kt`

package com.example.habits

import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.LocalDate

// One DataStore for the whole app. Small settings and lists belong here; big or relational data belongs in Room.
private val Context.dataStore by preferencesDataStore(name = "habits")

@Serializable
data class Habit(val id: Long, val name: String)

@Serializable
private data class Saved(val habits: List<Habit> = emptyList(), val done: Set<String> = emptySet())

/** Everything the screen needs: the habits, and which ones are ticked today. */
data class HabitsToday(val habits: List<Habit>, val doneIds: Set<Long>, val streaks: Map<Long, Int>)

class HabitStore(private val context: Context) {
    private val key = stringPreferencesKey("data")
    private val json = Json { ignoreUnknownKeys = true }

    private fun decode(raw: String?): Saved = raw?.let { runCatching { json.decodeFromString<Saved>(it) }.getOrNull() } ?: Saved()
    private suspend fun update(change: (Saved) -> Saved) {
        context.dataStore.edit { prefs -> prefs[key] = json.encodeToString(change(decode(prefs[key]))) }
    }

    // "2025-03-14#7" means habit 7 was done on 14 March 2025.
    private fun mark(date: LocalDate, id: Long) = "$date#$id"

    val today: Flow<HabitsToday> = context.dataStore.data.map { prefs ->
        val saved = decode(prefs[key])
        val today = LocalDate.now()
        HabitsToday(
            habits = saved.habits,
            doneIds = saved.habits.map { it.id }.filter { mark(today, it) in saved.done }.toSet(),
            streaks = saved.habits.associate { it.id to streak(saved.done, it.id, today) }
        )
    }

    suspend fun add(name: String) = update { it.copy(habits = it.habits + Habit(System.currentTimeMillis(), name.trim())) }

    suspend fun remove(id: Long) = update { s ->
        s.copy(habits = s.habits.filterNot { it.id == id }, done = s.done.filterNot { it.endsWith("#$id") }.toSet())
    }

    suspend fun toggleToday(id: Long) = update { s ->
        val m = mark(LocalDate.now(), id)
        s.copy(done = if (m in s.done) s.done - m else s.done + m)
    }

    /** Days in a row ending today (or yesterday, if today is not ticked yet). */
    private fun streak(done: Set<String>, id: Long, today: LocalDate): Int {
        var day = if (mark(today, id) in done) today else today.minusDays(1)
        var count = 0
        while (mark(day, id) in done) { count++; day = day.minusDays(1) }
        return count
    }
}

`app/src/main/java/com/example/habits/HabitViewModel.kt`

package com.example.habits

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class HabitViewModel(app: Application) : AndroidViewModel(app) {
    private val store = HabitStore(app)

    val today: StateFlow<HabitsToday> = store.today.stateIn(
        viewModelScope, SharingStarted.WhileSubscribed(5_000), HabitsToday(emptyList(), emptySet(), emptyMap())
    )

    fun add(name: String) { if (name.isNotBlank()) viewModelScope.launch { store.add(name) } }
    fun toggle(id: Long) { viewModelScope.launch { store.toggleToday(id) } }
    fun remove(id: Long) { viewModelScope.launch { store.remove(id) } }
}

`app/src/main/java/com/example/habits/ReminderWorker.kt`

package com.example.habits

import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit

private const val CHANNEL_ID = "habit_reminders"

/** WorkManager runs this in the background, even if the app is closed. */
class ReminderWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result {
        val today = HabitStore(applicationContext).today.first()
        val left = today.habits.count { it.id !in today.doneIds }
        if (left > 0) show(applicationContext, "You have $left habit${if (left == 1) "" else "s"} left today")
        return Result.success()
    }

    private fun show(context: Context, text: String) {
        // On Android 13 and newer the user must allow notifications first.
        if (Build.VERSION.SDK_INT >= 33 &&
            ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
        ) return

        val manager = context.getSystemService(NotificationManager::class.java)
        manager.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Habit reminders", NotificationManager.IMPORTANCE_DEFAULT))
        val notification = NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(android.R.drawable.ic_popup_reminder)
            .setContentTitle("Habit tracker")
            .setContentText(text)
            .setAutoCancel(true)
            .build()
        NotificationManagerCompat.from(context).notify(1, notification)
    }

    companion object {
        /** Schedules one check about every 24 hours. Calling it again keeps the existing schedule. */
        fun schedule(context: Context) {
            val request = PeriodicWorkRequestBuilder<ReminderWorker>(24, TimeUnit.HOURS).build()
            WorkManager.getInstance(context).enqueueUniquePeriodicWork("habit_reminder", ExistingPeriodicWorkPolicy.KEEP, request)
        }
    }
}

The worker creates the channel (safe to repeat) and posts the notification only if the permission is granted. A worker must finish quickly and return a result. Do not try to schedule exact alarms with WorkManager. If you need an exact time, such as an alarm clock, that is a different API with extra permissions.

`app/src/main/java/com/example/habits/HabitScreen.kt`

package com.example.habits

import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun HabitScreen(vm: HabitViewModel = viewModel()) {
    val today by vm.today.collectAsStateWithLifecycle()
    val context = LocalContext.current
    var name by remember { mutableStateOf("") }

    // Ask for the notification permission once, then start the daily reminder either way.
    val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { /* the worker checks it again */ }
    LaunchedEffect(Unit) {
        if (Build.VERSION.SDK_INT >= 33) permission.launch(Manifest.permission.POST_NOTIFICATIONS)
        ReminderWorker.schedule(context)
    }

    Column(Modifier.fillMaxSize().systemBarsPadding().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
        Text("Habits", style = MaterialTheme.typography.headlineMedium)
        Text("${today.doneIds.size} of ${today.habits.size} done today")
        Row(verticalAlignment = Alignment.CenterVertically) {
            OutlinedTextField(name, { name = it }, label = { Text("New habit, e.g. Walk 20 minutes") }, singleLine = true, modifier = Modifier.weight(1f))
            Spacer(Modifier.width(8.dp))
            Button(onClick = { vm.add(name); name = "" }) { Text("Add") }
        }
        if (today.habits.isEmpty()) Text("No habits yet. Add one small habit to begin.")
        LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
            items(today.habits, key = { it.id }) { habit ->
                val done = habit.id in today.doneIds
                Card(Modifier.fillMaxWidth()) {
                    Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
                        Checkbox(checked = done, onCheckedChange = { vm.toggle(habit.id) })
                        Column(Modifier.weight(1f)) {
                            Text(habit.name, style = MaterialTheme.typography.titleMedium)
                            Text("Streak: ${today.streaks[habit.id] ?: 0} days", style = MaterialTheme.typography.bodySmall)
                        }
                        IconButton(onClick = { vm.remove(habit.id) }) { Icon(Icons.Default.Delete, "Delete ${habit.name}") }
                    }
                }
            }
        }
    }
}

`app/src/main/java/com/example/habits/MainActivity.kt`

package com.example.habits

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { MaterialTheme { Surface { HabitScreen() } } }
    }
}

Expected output

The screen lists your habits with a check button for today and the streak, for example "Drink water, 3 day streak". Add the habits Read and Walk. Press Done on Read: the streak shows 1. On first launch Android asks for notification permission (Android 13 or newer). Allow it. A reminder is scheduled about once a day. For testing, change the interval or use the Work Manager inspector in Android Studio to run the job now, and a notification appears saying it is time for your habits. Close the app and reopen it: all habits and streaks are still there.

Your task

  1. Build the app, add two habits, mark one done and restart the app.
  2. Test the permission: deny it, and check the app still works without crashing.
  3. Use the App Inspection > Background Task Inspector to see the scheduled work.
  4. Write a test for the streak function with dates for today, yesterday and a gap.

How to check it worked

  • Habits and streaks persist after the app restarts.
  • Denying the permission does not crash the app.
  • You can explain why WorkManager reminders are not exact.

Frequently asked questions

DataStore or Room?

Use DataStore for small settings and simple data. Use Room when you have many rows, relations or need queries.

Why is the reminder time not exact?

WorkManager is designed to save battery, so Android may delay periodic work a little. Use it for reminders where a few minutes do not matter.

What happens if the user denies notifications?

The app should keep working and may explain why notifications help, with a link to settings. Never block the whole app on this permission.