Skip to main content

How to Build an Offline-Capable Android App with Jetpack Compose and Kotlin

· 6 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

The streak broke. So did the flow.

It wasn't that I forgot. I remembered, just a little too late.

Right before midnight, I opened the app to log my progress. But the screen just sat there, trying to connect. No internet. No log. No streak.

It sounds small, but if you've ever built a habit one day at a time, you know what a streak can mean. It's not just numbers. It's proof. And losing it? That stings.

That moment made one thing very clear: apps that help you grow should work with you, not against you, especially when the internet doesn't cooperate.

So let's build something better.

What We're Building: An Offline-Capable Habit Tracker

We'll build a habit tracker app that just works—whether you're in airplane mode, on a subway, or in the middle of the woods. The idea is simple: the user should be able to create, view, and check off habits anytime. The app will save everything locally and quietly sync with the cloud when the internet returns.

Here's what our app will include:

  • Local data storage using Room
  • A clean UI built with Jetpack Compose
  • Network sync using Retrofit
  • Background sync with WorkManager
  • Seamless experience whether you're online or offline

App Architecture Overview

To keep things maintainable and scalable, we'll use a layered architecture:

  • UI Layer - Jetpack Compose
  • ViewModel Layer - State management
  • Repository Layer - Business logic
  • Data Layer - Room (local), Retrofit (remote)
  • Worker Layer - Background sync with WorkManager

Step 1: Project Setup

First, make sure you have the required dependencies in your build.gradle (app level).

dependencies {
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.activity:activity-compose:1.7.2")
implementation("androidx.compose.material3:material3:1.1.1")
implementation("androidx.navigation:navigation-compose:2.6.0")

// Room
implementation("androidx.room:room-runtime:2.5.2")
implementation("androidx.room:room-ktx:2.5.2")
kapt("androidx.room:room-compiler:2.5.2")

// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")

// WorkManager
implementation("androidx.work:work-runtime-ktx:2.8.1")
}

Also enable kapt in your plugins block:

plugins {
id("kotlin-kapt")
}

Step 2: Define the Habit Model

We'll store habits locally using Room and remotely using a fake API (Retrofit). Start with your data model.

@Entity(tableName = "habits")
data class Habit(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val title: String,
val description: String,
val isCompleted: Boolean = false,
val lastModified: Long = System.currentTimeMillis()
)

Step 3: Room DAO and Database

To store habits locally on the device, we'll use Room, Android's official ORM (Object-Relational Mapping). First, define how habits are saved, retrieved, updated, and deleted using a DAO (Data Access Object), then wire it up with a Room database.

@Dao
interface HabitDao {
@Query("SELECT * FROM habits")
fun getAll(): Flow<List<Habit>>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(habit: Habit)

@Update
suspend fun update(habit: Habit)

@Delete
suspend fun delete(habit: Habit)
}
@Database(entities = [Habit::class], version = 1)
abstract class HabitDatabase : RoomDatabase() {
abstract fun habitDao(): HabitDao

companion object {
@Volatile private var INSTANCE: HabitDatabase? = null

fun getInstance(context: Context): HabitDatabase {
return INSTANCE ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
HabitDatabase::class.java,
"habit_db"
).build().also { INSTANCE = it }
}
}
}
}

Step 4: Create a Retrofit Interface for Syncing Habits

Even though we're building an offline-first app, we still want to sync our habit data with a remote server when the internet is available. To handle that, we'll set up a Retrofit interface. This defines the network calls our app can make.

interface HabitApi {
@GET("habits")
suspend fun getHabits(): List<Habit>

@POST("habit")
suspend fun addHabit(@Body habit: Habit)

@PUT("habit/{id}")
suspend fun updateHabit(@Path("id") id: String, @Body habit: Habit)
}

For now, we're just setting up the contract. The actual backend can be implemented later, or you can simulate the API responses during development. This keeps your architecture ready for online sync, without blocking your offline-first experience.


Step 5: Repository

This is where everything comes together. The repository decides whether to pull data from the local database or call the network. It also quietly handles sync failures, so your app doesn't crash when the internet vanishes.

class HabitRepository(
private val dao: HabitDao,
private val api: HabitApi
) {
val habits = dao.getAll()

suspend fun addHabit(habit: Habit) {
dao.insert(habit)
try {
api.addHabit(habit)
} catch (e: Exception) {
// Retry with WorkManager later
}
}

suspend fun syncHabits() {
try {
val remoteHabits = api.getHabits()
remoteHabits.forEach { dao.insert(it) }
} catch (_: Exception) {
// Stay silent
}
}
}

Step 6: ViewModel

Think of this as the brain behind your UI. It talks to the repository, keeps the UI updated, and makes sure all the heavy lifting happens off the main thread. No more frozen screens.

class HabitViewModel(private val repository: HabitRepository) : ViewModel() {
val habits = repository.habits.asLiveData()

fun addHabit(title: String, desc: String) {
val habit = Habit(title = title, description = desc)
viewModelScope.launch {
repository.addHabit(habit)
}
}

fun sync() {
viewModelScope.launch {
repository.syncHabits()
}
}
}

Step 7: Building the UI with Jetpack Compose

Here's where things come alive. Using Jetpack Compose, we build a simple, responsive UI where users can see their habits and add new ones - clean, smooth, and 100% Kotlin.

@Composable
fun HabitScreen(viewModel: HabitViewModel) {
val habits by viewModel.habits.observeAsState(emptyList())

LazyColumn {
items(habits) { habit ->
HabitItem(habit)
}
item {
AddHabitButton(viewModel)
}
}
}

@Composable
fun HabitItem(habit: Habit) {
Card(Modifier.padding(8.dp)) {
Column(Modifier.padding(16.dp)) {
Text(habit.title, fontWeight = FontWeight.Bold)
Text(habit.description)
}
}
}

Step 8: Background Sync with WorkManager

Your app shouldn't need the user's permission to keep data fresh. WorkManager helps you sync habits silently in the background. So even if the user forgets, the app doesn't.

class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val db = HabitDatabase.getInstance(applicationContext)
val repo = HabitRepository(db.habitDao(), /* provide api */)
return try {
repo.syncHabits()
Result.success()
} catch (_: Exception) {
Result.retry()
}
}
}

Schedule the sync:

val request = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"habit_sync",
ExistingPeriodicWorkPolicy.KEEP,
request
)

Final Touch: Handling Offline State

To make your app truly offline-friendly, here are a few important steps to wrap things up:

Detect network status

Create a small utility class (like NetworkUtils) to check if the device is connected to the internet.

Decide where to get data

Use the network status in your repository. If online, sync with the server. If offline, use Room to load or save data locally.

Handle syncing later

When offline, skip API calls. Just store data locally and let WorkManager handle background sync when the network returns.

Let users know

Show simple messages like "Saved locally. Will sync when online." so users understand what's happening.

Test with and without network

Write unit tests for your Repository and ViewModel. Also test the app with airplane mode on to make sure the UI still works as expected.


Closing Thoughts

Connectivity issues shouldn't break trust. Your app should feel reliable, even when the network is not.

By designing your Android apps with offline-first principles, you're not just improving user experience, you're building resilience into the core of your product.

And if you've ever lost a streak, a note, or a task to a poor connection… you know that kind of resilience matters.

Build it right, and your app becomes a habit worth keeping, even when the connection isn't.