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
- New Empty Activity project
Habits, packagecom.example.habits. - Add DataStore, WorkManager, serialization and lifecycle libraries, then sync.
- Add the permission lines from
AndroidManifest-additions.xmlto the manifest. - 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
- Build the app, add two habits, mark one done and restart the app.
- Test the permission: deny it, and check the app still works without crashing.
- Use the App Inspection > Background Task Inspector to see the scheduled work.
- 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.