How to Build an Offline-Capable Android App with Jetpack Compose and Kotlin
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.
